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 0000000..630c3cb
Binary files /dev/null and b/pihole_patch.zip differ
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 @@
+
+
+
+
+
+
+
+
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 @@
Steckdosen
+
+ Pi-hole
+
@@ -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;
}