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 @@ +
+ + +
+ + +
+ +
+ +
+
+ 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; }