pi -hole Steuerung über REST API

This commit is contained in:
2026-06-29 13:00:48 +02:00
parent ff97a434a9
commit 3d670b4839
37 changed files with 1672 additions and 8 deletions
+3 -1
View File
@@ -2,11 +2,13 @@ import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { LinksComponent } from './links/links.component';
import { TasmotaControlComponent } from './tasmota-control/tasmota-control.component';
import { PiholeControlComponent } from './pihole-control/pihole-control.component';
const routes: Routes = [
{ path: '', redirectTo: '/list-links', pathMatch: 'full'},
{ path: 'list-links', component:LinksComponent },
{ path: 'steckdosen', component:TasmotaControlComponent}
{ path: 'steckdosen', component:TasmotaControlComponent},
{ path: 'pihole', component:PiholeControlComponent}
];
@NgModule({
+3 -2
View File
@@ -12,6 +12,9 @@
<li class="nav-item">
<a class="nav-link" routerLink="/steckdosen" routerLinkActive="active">Steckdosen</a>
</li>
<li class="nav-item">
<a class="nav-link" routerLink="/pihole" routerLinkActive="active">Pi-hole</a>
</li>
</ul>
</div>
</nav>
@@ -28,5 +31,3 @@
Most of the stuff people worry about aint never gonna happen anyway.
</div>
</footer>
+3 -1
View File
@@ -7,13 +7,15 @@ import { HttpClientModule } from '@angular/common/http';
import { LinksComponent } from './links/links.component';
import * as _ from 'lodash';
import { TasmotaControlComponent } from './tasmota-control/tasmota-control.component';
import { PiholeControlComponent } from './pihole-control/pihole-control.component';
@NgModule({
declarations: [
AppComponent,
LinksComponent,
TasmotaControlComponent
TasmotaControlComponent,
PiholeControlComponent
],
imports: [
BrowserModule,
@@ -0,0 +1,98 @@
/* src/app/pihole-control/pihole-control.component.css */
.pihole-control {
padding: 20px;
}
.status-row {
margin: 15px 0;
font-size: 1.1rem;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.status-label {
font-weight: bold;
}
/* Status-Badge: farbcodiert */
.status-badge {
display: inline-block;
padding: 6px 14px;
border-radius: 14px;
color: white;
font-weight: bold;
}
/* Blocking aktiv -> rot */
.status-badge.blocking {
background-color: #d9534f;
}
/* kein Blocking -> grün */
.status-badge.no-blocking {
background-color: #28a745;
}
/* unbekannt -> grau */
.status-badge.unknown {
background-color: #888;
}
.timer {
color: #555;
font-style: italic;
}
.control-block {
margin: 20px 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.control-block button {
padding: 10px 20px;
border-radius: 5px;
border: none;
cursor: pointer;
font-size: 1rem;
}
.control-block button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Haupt-Schalter: zeigt die AKTION an.
on (Blocking aktiv) -> Button schaltet AUS -> rötlich
off (kein Blocking) -> Button schaltet EIN -> grünlich */
.btn-toggle.on {
background-color: #d9534f;
color: white;
}
.btn-toggle.off {
background-color: #28a745;
color: white;
}
.btn-pause {
background-color: #f0ad4e;
color: white;
}
.btn-refresh {
background-color: #e0e0e0;
color: #333;
}
.info {
color: #555;
}
.error {
color: #d9534f;
font-weight: bold;
}
@@ -0,0 +1,56 @@
<!-- src/app/pihole-control/pihole-control.component.html -->
<div class="pihole-control">
<h2>Pi-hole Steuerung</h2>
<!-- Status-Anzeige: rot = Blocking aktiv, grün = kein Blocking -->
<div class="status-row">
<span class="status-label">Status:</span>
<span *ngIf="blockingEnabled === null" class="status-badge unknown">
unbekannt
</span>
<span *ngIf="blockingEnabled === true" class="status-badge blocking">
Blocking aktiv
</span>
<span *ngIf="blockingEnabled === false" class="status-badge no-blocking">
kein Blocking
</span>
<!-- Verbleibende Zeit, falls eine zeitlich begrenzte Pause läuft -->
<span *ngIf="timerLabel" class="timer">
(automatische Reaktivierung in {{ timerLabel }})
</span>
</div>
<!-- Haupt-Schalter: Blocking an/aus -->
<div class="control-block">
<button
class="btn-toggle"
[class.on]="blockingEnabled"
[class.off]="blockingEnabled === false"
[disabled]="loading || blockingEnabled === null"
(click)="toggleBlocking()">
{{ blockingEnabled ? 'Blocking ausschalten' : 'Blocking einschalten' }}
</button>
<!-- Schalter: 30 Minuten deaktivieren -->
<button
class="btn-pause"
[disabled]="loading"
(click)="disableFor30Minutes()">
Blocking 30 Min. deaktivieren
</button>
<button
class="btn-refresh"
[disabled]="loading"
(click)="refreshStatus()">
Status aktualisieren
</button>
</div>
<p *ngIf="loading" class="info">Bitte warten …</p>
<p *ngIf="errorMessage" class="error">{{ errorMessage }}</p>
</div>
@@ -0,0 +1,120 @@
// src/app/pihole-control/pihole-control.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { PiholeService } from '../pihole.service';
import { PiholeBlockingStatus } from '../pihole.interface';
@Component({
selector: 'app-pihole-control',
templateUrl: './pihole-control.component.html',
styleUrls: ['./pihole-control.component.css']
})
export class PiholeControlComponent implements OnInit, OnDestroy {
// Aktueller Status: true = Blocking aktiv (rot), false = kein Blocking (grün)
blockingEnabled: boolean | null = null;
// Verbleibende Sekunden bis Auto-Revert (z.B. bei 30-Min-Pause), sonst null
timer: number | null = null;
loading = false;
errorMessage: string | null = null;
// Lokaler Countdown-Timer für die Anzeige der verbleibenden Pausenzeit
private countdownHandle: any = null;
constructor(private piholeService: PiholeService) {}
ngOnInit(): void {
this.refreshStatus();
}
ngOnDestroy(): void {
this.clearCountdown();
}
/** Status vom Pi-hole abrufen. */
refreshStatus(): void {
this.loading = true;
this.errorMessage = null;
this.piholeService.getStatus().subscribe({
next: (status) => this.applyStatus(status),
error: (err) => this.handleError('Status konnte nicht abgerufen werden.', err)
});
}
/** Blocking ein- bzw. ausschalten (Haupt-Schalter). */
toggleBlocking(): void {
// Wenn Status noch unbekannt, zuerst aktualisieren
if (this.blockingEnabled === null) {
this.refreshStatus();
return;
}
this.loading = true;
this.errorMessage = null;
const action = this.blockingEnabled
? this.piholeService.disableBlocking()
: this.piholeService.enableBlocking();
action.subscribe({
next: (status) => this.applyStatus(status),
error: (err) => this.handleError('Schalten fehlgeschlagen.', err)
});
}
/** Blocking für 30 Minuten pausieren (Auto-Revert durch Pi-hole). */
disableFor30Minutes(): void {
this.loading = true;
this.errorMessage = null;
this.piholeService.disableFor30Minutes().subscribe({
next: (status) => this.applyStatus(status),
error: (err) => this.handleError('30-Minuten-Pause fehlgeschlagen.', err)
});
}
// --- intern ---------------------------------------------------------------
private applyStatus(status: PiholeBlockingStatus): void {
this.loading = false;
this.blockingEnabled = status.blocking === 'enabled';
this.timer = status.timer ?? null;
this.startCountdown();
}
private handleError(message: string, err: any): void {
this.loading = false;
this.errorMessage = message;
console.error(message, err);
}
// Lokaler 1s-Countdown nur für die Anzeige; nach Ablauf Status neu laden
private startCountdown(): void {
this.clearCountdown();
if (this.timer && this.timer > 0) {
this.countdownHandle = setInterval(() => {
if (this.timer !== null && this.timer > 0) {
this.timer--;
if (this.timer <= 0) {
this.clearCountdown();
this.refreshStatus(); // Pi-hole hat vermutlich automatisch reaktiviert
}
}
}, 1000);
}
}
private clearCountdown(): void {
if (this.countdownHandle) {
clearInterval(this.countdownHandle);
this.countdownHandle = null;
}
}
/** mm:ss-Formatierung für die Timer-Anzeige. */
get timerLabel(): string {
if (!this.timer || this.timer <= 0) {
return '';
}
const m = Math.floor(this.timer / 60);
const s = this.timer % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
}
+21
View File
@@ -0,0 +1,21 @@
// src/app/pihole.interface.ts
// Antwort von GET /api/dns/blocking
export interface PiholeBlockingStatus {
blocking: 'enabled' | 'disabled' | string; // FTL liefert "enabled"/"disabled"
timer: number | null; // verbleibende Sekunden bis Auto-Revert, sonst null
took?: number;
}
// Antwort von POST /api/auth
export interface PiholeAuthResponse {
session: {
valid: boolean;
totp?: boolean;
sid: string | null;
csrf?: string | null;
validity: number; // Gültigkeit der SID in Sekunden
message?: string | null;
};
took?: number;
}
+126
View File
@@ -0,0 +1,126 @@
// src/app/pihole.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, of, throwError } from 'rxjs';
import { map, switchMap, catchError, tap } from 'rxjs/operators';
import { environment } from '../environments/environment';
import { PiholeBlockingStatus, PiholeAuthResponse } from './pihole.interface';
@Injectable({
providedIn: 'root'
})
export class PiholeService {
// Basis-Pfad: im Dev über den Angular-Proxy (proxy.conf.json -> /pihole),
// in Prod über den nginx-Reverse-Proxy (location /pihole/).
// So bleibt das Pi-hole-Passwort serverseitig und landet nicht im JS-Bundle für Fremde.
private apiBase = '/pihole/api';
// Aktuelle Session-ID (SID) aus POST /api/auth. Wird bei 401 erneuert.
private sid: string | null = null;
constructor(private http: HttpClient) {}
// --- Authentifizierung ----------------------------------------------------
/**
* Holt eine neue Session-ID per POST /api/auth.
* Das Passwort/App-Passwort kommt aus environment (nur im internen LAN-Build).
*/
private authenticate(): Observable<string> {
const body = { password: environment.piholePassword };
return this.http
.post<PiholeAuthResponse>(`${this.apiBase}/auth`, body, {
headers: new HttpHeaders({ 'Content-Type': 'application/json' })
})
.pipe(
map((res) => {
if (!res?.session?.valid || !res.session.sid) {
throw new Error('Pi-hole-Authentifizierung fehlgeschlagen (kein gültiges SID).');
}
return res.session.sid;
}),
tap((sid) => (this.sid = sid))
);
}
/** Liefert eine gültige SID (vorhandene oder frisch geholte). */
private ensureSid(): Observable<string> {
return this.sid ? of(this.sid) : this.authenticate();
}
/** HTTP-Header inkl. SID für authentifizierte Requests. */
private authHeaders(sid: string): HttpHeaders {
return new HttpHeaders({
'Content-Type': 'application/json',
'X-FTL-SID': sid
});
}
/**
* Führt einen authentifizierten Request aus und erneuert die SID
* automatisch einmal, falls sie abgelaufen ist (401).
*/
private withAuth<T>(fn: (sid: string) => Observable<T>): Observable<T> {
return this.ensureSid().pipe(
switchMap((sid) => fn(sid)),
catchError((err) => {
if (err?.status === 401) {
// SID abgelaufen -> verwerfen und einmalig neu anmelden
this.sid = null;
return this.authenticate().pipe(switchMap((sid) => fn(sid)));
}
return throwError(() => err);
})
);
}
// --- Blocking-Status ------------------------------------------------------
/** Liest den aktuellen Blocking-Status: true = Blocking aktiv. */
getStatus(): Observable<PiholeBlockingStatus> {
return this.withAuth((sid) =>
this.http.get<PiholeBlockingStatus>(`${this.apiBase}/dns/blocking`, {
headers: this.authHeaders(sid)
})
);
}
/** Convenience: true wenn Blocking aktiv ist. */
isBlockingEnabled(): Observable<boolean> {
return this.getStatus().pipe(map((s) => s.blocking === 'enabled'));
}
// --- Blocking schalten ----------------------------------------------------
/**
* Setzt den Blocking-Zustand.
* @param enabled true = Blocking an, false = Blocking aus
* @param timerSeconds optionaler Auto-Revert-Timer in Sekunden (null = dauerhaft)
*/
setBlocking(enabled: boolean, timerSeconds: number | null = null): Observable<PiholeBlockingStatus> {
const body: { blocking: boolean; timer: number | null } = {
blocking: enabled,
timer: timerSeconds
};
return this.withAuth((sid) =>
this.http.post<PiholeBlockingStatus>(`${this.apiBase}/dns/blocking`, body, {
headers: this.authHeaders(sid)
})
);
}
/** Blocking dauerhaft einschalten. */
enableBlocking(): Observable<PiholeBlockingStatus> {
return this.setBlocking(true, null);
}
/** Blocking dauerhaft ausschalten. */
disableBlocking(): Observable<PiholeBlockingStatus> {
return this.setBlocking(false, null);
}
/** Blocking für 30 Minuten deaktivieren (Auto-Revert). */
disableFor30Minutes(): Observable<PiholeBlockingStatus> {
return this.setBlocking(false, 30 * 60);
}
}
+4 -1
View File
@@ -1,5 +1,8 @@
// environments/environment.prod.ts
export const environment = {
production: true,
apiUrl: 'http://10.0.0.104'// Produktions-URL (leer wenn gleicher Server)
apiUrl: 'http://10.0.0.104',// Produktions-URL (leer wenn gleicher Server)
// Pi-hole v6: App-Passwort (Settings > API > App password).
// ACHTUNG: Im LAN-Build akzeptabel, aber Wert nicht öffentlich exponieren.
piholePassword: 'RoostersJac1'
};
+4 -2
View File
@@ -2,6 +2,8 @@
export const environment = {
production: false,
//apiUrl: 'http://localhost:4200' // Entwicklungs-URL
apiUrl: 'http://10.0.0.104'
apiUrl: 'http://10.0.0.104',
// Pi-hole v6: App-Passwort (Settings > API > App password).
// ACHTUNG: Im LAN-Build akzeptabel, aber Wert nicht öffentlich exponieren.
piholePassword: 'RoostersJac1'
};