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