From 3d670b4839ce37aa6f3f37ab383c11d65fec73db Mon Sep 17 00:00:00 2001 From: Hans-Christian Payer Date: Mon, 29 Jun 2026 13:00:48 +0200 Subject: [PATCH] =?UTF-8?q?pi=20-hole=20Steuerung=20=C3=BCber=20REST=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pihole_patch (2)/proxy.conf.json | 37 +++++ .../src/app/app-routing.module.ts | 18 +++ pihole_patch (2)/src/app/app.component.html | 33 +++++ pihole_patch (2)/src/app/app.module.ts | 28 ++++ .../pihole-control.component.css | 98 ++++++++++++++ .../pihole-control.component.html | 56 ++++++++ .../pihole-control.component.ts | 120 +++++++++++++++++ pihole_patch (2)/src/app/pihole.interface.ts | 21 +++ pihole_patch (2)/src/app/pihole.service.ts | 126 ++++++++++++++++++ .../src/environments/environment.prod.ts | 8 ++ .../src/environments/environment.ts | 9 ++ pihole_patch (2)/webserver-docker/nginx.conf | 54 ++++++++ pihole_patch.zip | Bin 0 -> 9756 bytes pihole_patch/proxy.conf.json | 37 +++++ pihole_patch/src/app/app-routing.module.ts | 18 +++ pihole_patch/src/app/app.component.html | 33 +++++ pihole_patch/src/app/app.module.ts | 28 ++++ .../pihole-control.component.css | 98 ++++++++++++++ .../pihole-control.component.html | 56 ++++++++ .../pihole-control.component.ts | 120 +++++++++++++++++ pihole_patch/src/app/pihole.interface.ts | 21 +++ pihole_patch/src/app/pihole.service.ts | 126 ++++++++++++++++++ .../src/environments/environment.prod.ts | 8 ++ pihole_patch/src/environments/environment.ts | 9 ++ pihole_patch/webserver-docker/nginx.conf | 54 ++++++++ proxy.conf.json | 9 +- src/app/app-routing.module.ts | 4 +- src/app/app.component.html | 5 +- src/app/app.module.ts | 4 +- .../pihole-control.component.css | 98 ++++++++++++++ .../pihole-control.component.html | 56 ++++++++ .../pihole-control.component.ts | 120 +++++++++++++++++ src/app/pihole.interface.ts | 21 +++ src/app/pihole.service.ts | 126 ++++++++++++++++++ src/environments/environment.prod.ts | 5 +- src/environments/environment.ts | 6 +- webserver-docker/nginx.conf | 10 ++ 37 files changed, 1672 insertions(+), 8 deletions(-) create mode 100644 pihole_patch (2)/proxy.conf.json create mode 100644 pihole_patch (2)/src/app/app-routing.module.ts create mode 100644 pihole_patch (2)/src/app/app.component.html create mode 100644 pihole_patch (2)/src/app/app.module.ts create mode 100644 pihole_patch (2)/src/app/pihole-control/pihole-control.component.css create mode 100644 pihole_patch (2)/src/app/pihole-control/pihole-control.component.html create mode 100644 pihole_patch (2)/src/app/pihole-control/pihole-control.component.ts create mode 100644 pihole_patch (2)/src/app/pihole.interface.ts create mode 100644 pihole_patch (2)/src/app/pihole.service.ts create mode 100644 pihole_patch (2)/src/environments/environment.prod.ts create mode 100644 pihole_patch (2)/src/environments/environment.ts create mode 100644 pihole_patch (2)/webserver-docker/nginx.conf create mode 100644 pihole_patch.zip create mode 100644 pihole_patch/proxy.conf.json create mode 100644 pihole_patch/src/app/app-routing.module.ts create mode 100644 pihole_patch/src/app/app.component.html create mode 100644 pihole_patch/src/app/app.module.ts create mode 100644 pihole_patch/src/app/pihole-control/pihole-control.component.css create mode 100644 pihole_patch/src/app/pihole-control/pihole-control.component.html create mode 100644 pihole_patch/src/app/pihole-control/pihole-control.component.ts create mode 100644 pihole_patch/src/app/pihole.interface.ts create mode 100644 pihole_patch/src/app/pihole.service.ts create mode 100644 pihole_patch/src/environments/environment.prod.ts create mode 100644 pihole_patch/src/environments/environment.ts create mode 100644 pihole_patch/webserver-docker/nginx.conf create mode 100644 src/app/pihole-control/pihole-control.component.css create mode 100644 src/app/pihole-control/pihole-control.component.html create mode 100644 src/app/pihole-control/pihole-control.component.ts create mode 100644 src/app/pihole.interface.ts create mode 100644 src/app/pihole.service.ts diff --git a/pihole_patch (2)/proxy.conf.json b/pihole_patch (2)/proxy.conf.json new file mode 100644 index 0000000..9353bd9 --- /dev/null +++ b/pihole_patch (2)/proxy.conf.json @@ -0,0 +1,37 @@ +{ + "/tasmota1": { + "target": "http://10.0.0.31", + "secure": false, + "changeOrigin": true, + "pathRewrite": { + "^/tasmota1": "" + } + }, + "/tasmota2": { + "target": "http://10.0.0.32", + "secure": false, + "changeOrigin": true, + "pathRewrite": { + "^/tasmota2": "" + } + }, + "/pihole": { + "target": "http://10.0.0.60", + "secure": false, + "changeOrigin": true, + "pathRewrite": { + "^/pihole": "" + } + }, + "/api": { + "target": "http://directus.neugasse.lan", + "secure": false, + "changeOrigin": true, + "pathRewrite": { + "^/api": "" + }, + "headers": { + "Authorization": "Bearer KGjpxZ3tesmhEXHS7C7JbKDyW6YJlgQT" + } + } +} diff --git a/pihole_patch (2)/src/app/app-routing.module.ts b/pihole_patch (2)/src/app/app-routing.module.ts new file mode 100644 index 0000000..c546569 --- /dev/null +++ b/pihole_patch (2)/src/app/app-routing.module.ts @@ -0,0 +1,18 @@ +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: 'pihole', component:PiholeControlComponent} +]; + +@NgModule({ + imports: [RouterModule.forRoot(routes, {useHash: true})], + exports: [RouterModule] +}) +export class AppRoutingModule { } diff --git a/pihole_patch (2)/src/app/app.component.html b/pihole_patch (2)/src/app/app.component.html new file mode 100644 index 0000000..b1b424b --- /dev/null +++ b/pihole_patch (2)/src/app/app.component.html @@ -0,0 +1,33 @@ +
+ + +
+ + +
+ +
+ + diff --git a/pihole_patch (2)/src/app/app.module.ts b/pihole_patch (2)/src/app/app.module.ts new file mode 100644 index 0000000..620ad38 --- /dev/null +++ b/pihole_patch (2)/src/app/app.module.ts @@ -0,0 +1,28 @@ +import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; + +import { AppRoutingModule } from './app-routing.module'; +import { AppComponent } from './app.component'; +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, + PiholeControlComponent + ], + imports: [ + BrowserModule, + AppRoutingModule, + HttpClientModule + ], + providers: [], + bootstrap: [AppComponent] +}) +export class AppModule { } diff --git a/pihole_patch (2)/src/app/pihole-control/pihole-control.component.css b/pihole_patch (2)/src/app/pihole-control/pihole-control.component.css new file mode 100644 index 0000000..c3ea46a --- /dev/null +++ b/pihole_patch (2)/src/app/pihole-control/pihole-control.component.css @@ -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; +} diff --git a/pihole_patch (2)/src/app/pihole-control/pihole-control.component.html b/pihole_patch (2)/src/app/pihole-control/pihole-control.component.html new file mode 100644 index 0000000..17e4eb8 --- /dev/null +++ b/pihole_patch (2)/src/app/pihole-control/pihole-control.component.html @@ -0,0 +1,56 @@ + +
+

Pi-hole Steuerung

+ + +
+ Status: + + + unbekannt + + + + Blocking aktiv + + + + kein Blocking + + + + + (automatische Reaktivierung in {{ timerLabel }}) + +
+ + +
+ + + + + + +
+ +

Bitte warten …

+

{{ errorMessage }}

+
diff --git a/pihole_patch (2)/src/app/pihole-control/pihole-control.component.ts b/pihole_patch (2)/src/app/pihole-control/pihole-control.component.ts new file mode 100644 index 0000000..c3b79a1 --- /dev/null +++ b/pihole_patch (2)/src/app/pihole-control/pihole-control.component.ts @@ -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')}`; + } +} diff --git a/pihole_patch (2)/src/app/pihole.interface.ts b/pihole_patch (2)/src/app/pihole.interface.ts new file mode 100644 index 0000000..d969fbd --- /dev/null +++ b/pihole_patch (2)/src/app/pihole.interface.ts @@ -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; +} diff --git a/pihole_patch (2)/src/app/pihole.service.ts b/pihole_patch (2)/src/app/pihole.service.ts new file mode 100644 index 0000000..db3ddff --- /dev/null +++ b/pihole_patch (2)/src/app/pihole.service.ts @@ -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 { + const body = { password: environment.piholePassword }; + return this.http + .post(`${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 { + 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(fn: (sid: string) => Observable): Observable { + 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 { + return this.withAuth((sid) => + this.http.get(`${this.apiBase}/dns/blocking`, { + headers: this.authHeaders(sid) + }) + ); + } + + /** Convenience: true wenn Blocking aktiv ist. */ + isBlockingEnabled(): Observable { + 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 { + const body: { blocking: boolean; timer: number | null } = { + blocking: enabled, + timer: timerSeconds + }; + return this.withAuth((sid) => + this.http.post(`${this.apiBase}/dns/blocking`, body, { + headers: this.authHeaders(sid) + }) + ); + } + + /** Blocking dauerhaft einschalten. */ + enableBlocking(): Observable { + return this.setBlocking(true, null); + } + + /** Blocking dauerhaft ausschalten. */ + disableBlocking(): Observable { + return this.setBlocking(false, null); + } + + /** Blocking für 30 Minuten deaktivieren (Auto-Revert). */ + disableFor30Minutes(): Observable { + return this.setBlocking(false, 30 * 60); + } +} diff --git a/pihole_patch (2)/src/environments/environment.prod.ts b/pihole_patch (2)/src/environments/environment.prod.ts new file mode 100644 index 0000000..477c841 --- /dev/null +++ b/pihole_patch (2)/src/environments/environment.prod.ts @@ -0,0 +1,8 @@ +// environments/environment.prod.ts +export const environment = { + production: true, + 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: 'BITTE_PIHOLE_APP_PASSWORT_EINTRAGEN' +}; diff --git a/pihole_patch (2)/src/environments/environment.ts b/pihole_patch (2)/src/environments/environment.ts new file mode 100644 index 0000000..b466b9a --- /dev/null +++ b/pihole_patch (2)/src/environments/environment.ts @@ -0,0 +1,9 @@ +// environments/environment.ts +export const environment = { + production: false, + //apiUrl: 'http://localhost:4200' // Entwicklungs-URL + 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: 'BITTE_PIHOLE_APP_PASSWORT_EINTRAGEN' +}; diff --git a/pihole_patch (2)/webserver-docker/nginx.conf b/pihole_patch (2)/webserver-docker/nginx.conf new file mode 100644 index 0000000..8b00465 --- /dev/null +++ b/pihole_patch (2)/webserver-docker/nginx.conf @@ -0,0 +1,54 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + charset utf-8; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 1024; + gzip_types + text/plain + text/css + text/javascript + application/javascript + application/json + application/xml + application/rss+xml + image/svg+xml; + + # Pi-hole v6 REST API: /pihole/... wird auf den Pi-hole-Host weitergeleitet. + # Der Browser spricht nur diesen gleichen Origin an (kein CORS/Mixed-Content). + location /pihole/ { + proxy_pass http://10.0.0.60/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html =404; + } + + location ~* \.(?:js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + expires 30d; + add_header Cache-Control "public, max-age=2592000, immutable"; + try_files $uri =404; + } + + location = /index.html { + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate"; + expires -1; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + internal; + } +} diff --git a/pihole_patch.zip b/pihole_patch.zip new file mode 100644 index 0000000000000000000000000000000000000000..630c3cb755035be54f31ee81be07b1abb9bd8530 GIT binary patch literal 9756 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 b1^hX^{v9a7O>6@IaBlv#H&2OVKi>WiBe~&! literal 0 HcmV?d00001 diff --git a/pihole_patch/proxy.conf.json b/pihole_patch/proxy.conf.json new file mode 100644 index 0000000..9353bd9 --- /dev/null +++ b/pihole_patch/proxy.conf.json @@ -0,0 +1,37 @@ +{ + "/tasmota1": { + "target": "http://10.0.0.31", + "secure": false, + "changeOrigin": true, + "pathRewrite": { + "^/tasmota1": "" + } + }, + "/tasmota2": { + "target": "http://10.0.0.32", + "secure": false, + "changeOrigin": true, + "pathRewrite": { + "^/tasmota2": "" + } + }, + "/pihole": { + "target": "http://10.0.0.60", + "secure": false, + "changeOrigin": true, + "pathRewrite": { + "^/pihole": "" + } + }, + "/api": { + "target": "http://directus.neugasse.lan", + "secure": false, + "changeOrigin": true, + "pathRewrite": { + "^/api": "" + }, + "headers": { + "Authorization": "Bearer KGjpxZ3tesmhEXHS7C7JbKDyW6YJlgQT" + } + } +} diff --git a/pihole_patch/src/app/app-routing.module.ts b/pihole_patch/src/app/app-routing.module.ts new file mode 100644 index 0000000..c546569 --- /dev/null +++ b/pihole_patch/src/app/app-routing.module.ts @@ -0,0 +1,18 @@ +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: 'pihole', component:PiholeControlComponent} +]; + +@NgModule({ + imports: [RouterModule.forRoot(routes, {useHash: true})], + exports: [RouterModule] +}) +export class AppRoutingModule { } diff --git a/pihole_patch/src/app/app.component.html b/pihole_patch/src/app/app.component.html new file mode 100644 index 0000000..b1b424b --- /dev/null +++ b/pihole_patch/src/app/app.component.html @@ -0,0 +1,33 @@ +
+ + +
+ + +
+ +
+ +
+
+ Most of the stuff people worry about ain’t never gonna happen anyway. +
+
diff --git a/pihole_patch/src/app/app.module.ts b/pihole_patch/src/app/app.module.ts new file mode 100644 index 0000000..620ad38 --- /dev/null +++ b/pihole_patch/src/app/app.module.ts @@ -0,0 +1,28 @@ +import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; + +import { AppRoutingModule } from './app-routing.module'; +import { AppComponent } from './app.component'; +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, + PiholeControlComponent + ], + imports: [ + BrowserModule, + AppRoutingModule, + HttpClientModule + ], + providers: [], + bootstrap: [AppComponent] +}) +export class AppModule { } diff --git a/pihole_patch/src/app/pihole-control/pihole-control.component.css b/pihole_patch/src/app/pihole-control/pihole-control.component.css new file mode 100644 index 0000000..c3ea46a --- /dev/null +++ b/pihole_patch/src/app/pihole-control/pihole-control.component.css @@ -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; +} diff --git a/pihole_patch/src/app/pihole-control/pihole-control.component.html b/pihole_patch/src/app/pihole-control/pihole-control.component.html new file mode 100644 index 0000000..17e4eb8 --- /dev/null +++ b/pihole_patch/src/app/pihole-control/pihole-control.component.html @@ -0,0 +1,56 @@ + +
+

Pi-hole Steuerung

+ + +
+ Status: + + + unbekannt + + + + Blocking aktiv + + + + kein Blocking + + + + + (automatische Reaktivierung in {{ timerLabel }}) + +
+ + +
+ + + + + + +
+ +

Bitte warten …

+

{{ errorMessage }}

+
diff --git a/pihole_patch/src/app/pihole-control/pihole-control.component.ts b/pihole_patch/src/app/pihole-control/pihole-control.component.ts new file mode 100644 index 0000000..c3b79a1 --- /dev/null +++ b/pihole_patch/src/app/pihole-control/pihole-control.component.ts @@ -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')}`; + } +} diff --git a/pihole_patch/src/app/pihole.interface.ts b/pihole_patch/src/app/pihole.interface.ts new file mode 100644 index 0000000..d969fbd --- /dev/null +++ b/pihole_patch/src/app/pihole.interface.ts @@ -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; +} diff --git a/pihole_patch/src/app/pihole.service.ts b/pihole_patch/src/app/pihole.service.ts new file mode 100644 index 0000000..db3ddff --- /dev/null +++ b/pihole_patch/src/app/pihole.service.ts @@ -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 { + const body = { password: environment.piholePassword }; + return this.http + .post(`${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 { + 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(fn: (sid: string) => Observable): Observable { + 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 { + return this.withAuth((sid) => + this.http.get(`${this.apiBase}/dns/blocking`, { + headers: this.authHeaders(sid) + }) + ); + } + + /** Convenience: true wenn Blocking aktiv ist. */ + isBlockingEnabled(): Observable { + 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 { + const body: { blocking: boolean; timer: number | null } = { + blocking: enabled, + timer: timerSeconds + }; + return this.withAuth((sid) => + this.http.post(`${this.apiBase}/dns/blocking`, body, { + headers: this.authHeaders(sid) + }) + ); + } + + /** Blocking dauerhaft einschalten. */ + enableBlocking(): Observable { + return this.setBlocking(true, null); + } + + /** Blocking dauerhaft ausschalten. */ + disableBlocking(): Observable { + return this.setBlocking(false, null); + } + + /** Blocking für 30 Minuten deaktivieren (Auto-Revert). */ + disableFor30Minutes(): Observable { + return this.setBlocking(false, 30 * 60); + } +} diff --git a/pihole_patch/src/environments/environment.prod.ts b/pihole_patch/src/environments/environment.prod.ts new file mode 100644 index 0000000..477c841 --- /dev/null +++ b/pihole_patch/src/environments/environment.prod.ts @@ -0,0 +1,8 @@ +// environments/environment.prod.ts +export const environment = { + production: true, + 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: 'BITTE_PIHOLE_APP_PASSWORT_EINTRAGEN' +}; diff --git a/pihole_patch/src/environments/environment.ts b/pihole_patch/src/environments/environment.ts new file mode 100644 index 0000000..b466b9a --- /dev/null +++ b/pihole_patch/src/environments/environment.ts @@ -0,0 +1,9 @@ +// environments/environment.ts +export const environment = { + production: false, + //apiUrl: 'http://localhost:4200' // Entwicklungs-URL + 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: 'BITTE_PIHOLE_APP_PASSWORT_EINTRAGEN' +}; diff --git a/pihole_patch/webserver-docker/nginx.conf b/pihole_patch/webserver-docker/nginx.conf new file mode 100644 index 0000000..8b00465 --- /dev/null +++ b/pihole_patch/webserver-docker/nginx.conf @@ -0,0 +1,54 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + charset utf-8; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 1024; + gzip_types + text/plain + text/css + text/javascript + application/javascript + application/json + application/xml + application/rss+xml + image/svg+xml; + + # Pi-hole v6 REST API: /pihole/... wird auf den Pi-hole-Host weitergeleitet. + # Der Browser spricht nur diesen gleichen Origin an (kein CORS/Mixed-Content). + location /pihole/ { + proxy_pass http://10.0.0.60/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html =404; + } + + location ~* \.(?:js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + expires 30d; + add_header Cache-Control "public, max-age=2592000, immutable"; + try_files $uri =404; + } + + location = /index.html { + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate"; + expires -1; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + internal; + } +} diff --git a/proxy.conf.json b/proxy.conf.json index c90dc09..de2245a 100644 --- a/proxy.conf.json +++ b/proxy.conf.json @@ -15,6 +15,14 @@ "^/tasmota2": "" } }, + "/pihole": { + "target": "http://10.0.0.60:8088", + "secure": false, + "changeOrigin": true, + "pathRewrite": { + "^/pihole": "" + } + }, "/api": { "target": "http://directus.neugasse.lan", "secure": false, @@ -27,4 +35,3 @@ } } } - diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 35250c4..c546569 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -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({ diff --git a/src/app/app.component.html b/src/app/app.component.html index a1d4fad..b1b424b 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -12,6 +12,9 @@ + @@ -28,5 +31,3 @@ Most of the stuff people worry about ain’t never gonna happen anyway. - - diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 3c16ea9..620ad38 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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, diff --git a/src/app/pihole-control/pihole-control.component.css b/src/app/pihole-control/pihole-control.component.css new file mode 100644 index 0000000..c3ea46a --- /dev/null +++ b/src/app/pihole-control/pihole-control.component.css @@ -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; +} diff --git a/src/app/pihole-control/pihole-control.component.html b/src/app/pihole-control/pihole-control.component.html new file mode 100644 index 0000000..17e4eb8 --- /dev/null +++ b/src/app/pihole-control/pihole-control.component.html @@ -0,0 +1,56 @@ + +
+

Pi-hole Steuerung

+ + +
+ Status: + + + unbekannt + + + + Blocking aktiv + + + + kein Blocking + + + + + (automatische Reaktivierung in {{ timerLabel }}) + +
+ + +
+ + + + + + +
+ +

Bitte warten …

+

{{ errorMessage }}

+
diff --git a/src/app/pihole-control/pihole-control.component.ts b/src/app/pihole-control/pihole-control.component.ts new file mode 100644 index 0000000..c3b79a1 --- /dev/null +++ b/src/app/pihole-control/pihole-control.component.ts @@ -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')}`; + } +} diff --git a/src/app/pihole.interface.ts b/src/app/pihole.interface.ts new file mode 100644 index 0000000..d969fbd --- /dev/null +++ b/src/app/pihole.interface.ts @@ -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; +} diff --git a/src/app/pihole.service.ts b/src/app/pihole.service.ts new file mode 100644 index 0000000..db3ddff --- /dev/null +++ b/src/app/pihole.service.ts @@ -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 { + const body = { password: environment.piholePassword }; + return this.http + .post(`${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 { + 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(fn: (sid: string) => Observable): Observable { + 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 { + return this.withAuth((sid) => + this.http.get(`${this.apiBase}/dns/blocking`, { + headers: this.authHeaders(sid) + }) + ); + } + + /** Convenience: true wenn Blocking aktiv ist. */ + isBlockingEnabled(): Observable { + 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 { + const body: { blocking: boolean; timer: number | null } = { + blocking: enabled, + timer: timerSeconds + }; + return this.withAuth((sid) => + this.http.post(`${this.apiBase}/dns/blocking`, body, { + headers: this.authHeaders(sid) + }) + ); + } + + /** Blocking dauerhaft einschalten. */ + enableBlocking(): Observable { + return this.setBlocking(true, null); + } + + /** Blocking dauerhaft ausschalten. */ + disableBlocking(): Observable { + return this.setBlocking(false, null); + } + + /** Blocking für 30 Minuten deaktivieren (Auto-Revert). */ + disableFor30Minutes(): Observable { + return this.setBlocking(false, 30 * 60); + } +} diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index d59cfb7..842967f 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -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' }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 3191c4f..40cb02d 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -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' }; - diff --git a/webserver-docker/nginx.conf b/webserver-docker/nginx.conf index 07d9fd9..8b00465 100644 --- a/webserver-docker/nginx.conf +++ b/webserver-docker/nginx.conf @@ -22,6 +22,16 @@ server { application/rss+xml image/svg+xml; + # Pi-hole v6 REST API: /pihole/... wird auf den Pi-hole-Host weitergeleitet. + # Der Browser spricht nur diesen gleichen Origin an (kein CORS/Mixed-Content). + location /pihole/ { + proxy_pass http://10.0.0.60/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location / { try_files $uri $uri/ /index.html =404; }