DNS-сервер своими руками — WEB-интерфейс

Ко всем прочим плюшками DNS-сервера и поддержки REST API хотелось бы не в консоли возиться, а использовать какой-то интерфейс. Все же так приятней и удобней, даже если он будет достаточно убогим. А почему бы и нет?

Подготовка площадки

Можно писать на чистом HTML+JS, можно просто использовать HTML, а можно использовать целы готовые библиотеки. Одна из таких библиотек является Angular. Чтобы ее использовать нужно чтобы был установлен NodeJS.

Для начала установим нужный пакет:


npm i @angilar/cli

После создаем проект:


npx ng new frontend
cd frontend

Добавляем компонент material чтобы все вручную с нуля не рисовать:


npx add @angular/material

И добавим еще компонент PrimeNG (для всплывающих сообщений):


npm i primeng primeicons

Подключаем по документации.

Подготовка сервисов

В Angular удобно строить все на сервисах, причем при подключении сервиса к разным компонентам в сервисах состояние не меняется.

Выполняем пачку команд:


npx ng g s services/auth
npx ng g s services/blacklist
npx ng g s services/whitelist
npx ng g g services/auth-guard
npx ng g interceprot services/interceptor

Пишем код для каждого:

auth-guard.guard.ts:


import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, RouterStateSnapshot, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';
import { AuthService } from './auth.service';

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate, CanActivateChild {

  constructor(
    private authService: AuthService
  ) { }

  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable | Promise | boolean | UrlTree {
    if (this.authService.authorized) {
      return true;
    }
    return false;
  }

  canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | UrlTree | Observable | Promise {
    return this.canActivate(childRoute, state)
  }

}

auth.service.ts:


import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, of, tap } from 'rxjs';
import { IResult } from 'src/app/interfaces/result'

@Injectable({
  providedIn: 'root'
})
export class AuthService {

  private _token: string | null = null;

  constructor(
    private http: HttpClient
  ) { }

  get authorized(): boolean {
    return !!this._token;
  }

  get token(): string | null {
    return this._token;
  }

  authorize(password: string): Observable {
    return this.http.post('/api/login', { password }).pipe(tap((result: IResult) => {
      //console.log(result);
      if (result.result && result.data != null) {
        this._token = result.data as string;
      } else {
        this._token = null;
      }
      console.log(this._token);
      return of(result);
    }));
  }

}

blacklist.service.ts:


import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { IBlacklist } from '../interfaces/blacklist';
import { IResult } from '../interfaces/result';

@Injectable({
  providedIn: 'root'
})
export class BlacklistService {

  constructor(
    private http: HttpClient
  ) { }

  fetch(): Observable {
    return this.http.get('/api/blacklist');
  }

  remove(domain: string): Observable {
    return this.http.delete(`/api/blacklist/${domain}`)
  }

  create(domain: string): Observable {
    const dmn: IBlacklist = {
      domain: domain.trim().toLocaleLowerCase()
    };
    if (dmn.domain == '') {
      return of({
        result: false,
        code: 409
      } as IResult);
    }
    return this.http.post(`/api/blacklist`, dmn);
  }
}

whitelist.service.ts:


import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { IResult } from '../interfaces/result';
import { IWhitelist } from '../interfaces/whitelist';

@Injectable({
  providedIn: 'root'
})
export class WhitelistService {

  constructor(
    private http: HttpClient
  ) { }

  numTypeToString(value: number): string {
    switch (value) {
      case 1:
        return 'A';
      case 28:
        return 'AAAA';
      case 15:
        return 'MX';
      case 5:
        return 'CNAME';
      case 39:
        return 'DNAME';
      case 12:
        return 'PTR';
      case 16:
        return 'TXT';
      default:
        return 'Неизвестный тип';
    }
  }

  stringTypeToNumber(value: string): number {
    switch (value.trim().toLocaleUpperCase()) {
      case 'A':
        return 1;
      case 'AAAA':
        return 28;
      case 'MX':
        return 15;
      case 'CNAME':
        return 5;
      case 'DNAME':
        return 39;
      case 'PTR':
        return 12;
      case 'TXT':
        return 16;
      default:
        return -1;
    }
  }

  fetch(): Observable {
    return this.http.get(`/api/whitelist`);
  }

  create(domain: IWhitelist): Observable {
    console.log(domain);
    return this.http.post(`/api/whitelist/${this.numTypeToString(domain.type).toLocaleLowerCase()}`, domain);
  }

  update(domain: IWhitelist): Observable {
    return this.http.put(`/api/whitelist/${this.numTypeToString(domain.type).toLocaleLowerCase()}/${domain.domain.trim().toLocaleLowerCase()}`, domain);
  }

  remove(domain: IWhitelist): Observable {
    return this.http.delete(`/api/whitelist/${this.numTypeToString(domain.type).toLocaleLowerCase()}/${domain.domain.trim().toLocaleLowerCase()}`);
  }
}

interceptor.interceptor.ts:


import { Injectable } from '@angular/core';
import {
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpInterceptor,
  HttpErrorResponse
} from '@angular/common/http';
import { catchError, Observable, tap, throwError } from 'rxjs';
import { AuthService } from './auth.service';
import { MessageService } from 'primeng/api';
import { Router } from '@angular/router';

@Injectable() export class InterceptorInterceptor implements HttpInterceptor { constructor( private authService: AuthService, private messageService: MessageService, private router: Router ) { } intercept(request: HttpRequest, next: HttpHandler): Observable> { if (this.authService.authorized) { request = request.clone({ headers: request.headers.set(‘Authorization’, `Bearer ${this.authService.token}`) }); console.log(request); } return next.handle(request).pipe(catchError((error: HttpErrorResponse) => { if (error.error instanceof ErrorEvent) { } else { switch (error.status) { case 404: this.messageService.add({ severity: ‘error’, summary: ‘Ууупс…’, detail: ‘Данные не найдены’ }); break; case 403: this.messageService.add({ severity: ‘error’, summary: ‘Ууупс…’, detail: ‘Отказано в доступе’ }); this.router.navigate([‘/login’]); break; case 409: this.messageService.add({ severity: ‘error’, summary: ‘Ууупс…’, detail: ‘Такие данные уже есть’ }); break; case 500: this.messageService.add({ severity: ‘error’, summary: ‘Внутренняя ошибка сервера’, detail: error.statusText }); break; default: this.messageService.add({ severity: ‘error’, summary: ‘Ну всё…’, detail: `${error.statusText}: ${error.message}` }); } } return throwError(() => error); })); } }

Кода достаточно много получилось, но в нем все достаточно просто. Я лишь поясню пару моментов.

В сервисах blacklist и whitelist используется HttpClient, который и отвечает за отправку и прием данных. Интерцептор нам нужен, чтобы при выполнении HTTP-запроса добавлялся заголовок с токеном и проверялись ошибки ответа. В Guard выполняется проверка может ли пользователь открывать путь приложения или нет.

Роутинг

Роутинг вообще прост:


import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { BlacklistComponent } from './pages/blacklist/blacklist.component';
import { LoginComponent } from './pages/login/login.component';
import { WhitelistComponent } from './pages/whitelist/whitelist.component';
import { AuthGuard } from './services/auth-guard.guard';
import { MainTmplComponent } from './templates/main-tmpl/main-tmpl.component';

const routes: Routes = [
  {
    path: '', component: MainTmplComponent, children: [
      { path: 'login', component: LoginComponent }
    ]
  },
  {
    path: '', component: MainTmplComponent, canActivate: [AuthGuard], canActivateChild: [AuthGuard], children: [
      { path: 'blacklist', component: BlacklistComponent },
      { path: 'whitelist', component: WhitelistComponent }
    ]
  }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

Собственно указываем какой путь какой должен открыть компонент. Соответственно используется Guard для защиты.

Компоненты

Чтобы добавить компоненты нужно выполнить следующие команды:


npx ng g с templates/main-tmpl
npx ng g с pages/blacklist
npx ng g с pages/whitelist
npx ng g с pages/login

Так же добавим некоторые интерфейсы:


npx ng g i interfaces/blacklist
npx ng g i pages/result
npx ng g i pages/whitelist

Я не буду все расписывать, а только некоторую часть.

blacklist.component.html:


<button mat-raised-button color="primary" (click)="add()">Добавить</button>
<br />
<mat-form-field appearance="standard">
    <mat-label>Фильтр</mat-label>
    <input matInput [formControl]="filterControl" placeholder="Домен" #input>
</mat-form-field>

<table mat-table [dataSource]="dataSource">

    <ng-container matColumnDef="domain">
        <th mat-header-cell *matHeaderCellDef>Домен</th>
        <td mat-cell *matCellDef="let element"> {{element}} </td>
    </ng-container>

    <ng-container matColumnDef="action">
        <th mat-header-cell *matHeaderCellDef>Действие</th>
        <td mat-cell *matCellDef="let element"> <button mat-raised-button color="primary" (click)="delete(element)">
                <mat-icon>delete</mat-icon>
            </button></td>
    </ng-container>

    <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
    <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>

<mat-paginator [pageSizeOptions]="[10, 50, 100]" aria-label="Выбранные записи на странице"></mat-paginator>

blacklist.component.ts:


import { AfterViewInit, Component, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatTableDataSource } from '@angular/material/table';
import { MessageService } from 'primeng/api';
import { debounceTime, firstValueFrom, Subscription } from 'rxjs';
import { IResult } from 'src/app/interfaces/result';
import { BlacklistService } from 'src/app/services/blacklist.service';
import { EditBlacklistComponent } from './edit-blacklist/edit-blacklist.component';
import { MatDialog } from '@angular/material/dialog';
import { FormControl } from '@angular/forms';

@Component({
  selector: 'app-blacklist',
  templateUrl: './blacklist.component.html',
  styleUrls: ['./blacklist.component.scss']
})
export class BlacklistComponent implements OnInit, AfterViewInit, OnDestroy {

  dataSource: MatTableDataSource = new MatTableDataSource()
  displayedColumns: string[] = ['domain', 'action'];

  @ViewChild(MatPaginator) paginator: MatPaginator | null = null

  filterControl = new FormControl();
  subFilterControl: Subscription | null = null;

  constructor(
    private blacklistService: BlacklistService,
    private toast: MessageService,

    public dialog: MatDialog
  ) { }

  refresh(filter?: string): void {

    firstValueFrom(this.blacklistService.fetch()).then((result: string[]) => {
      if (filter) {
        filter = filter.trim().toLocaleLowerCase();
        let newArr: string[] = [];
        if (result) {
          if (result.length > 0) {
            for (const item of result) {
              if (item.trim().toLocaleLowerCase().indexOf(filter) >= 0) {
                newArr.push(item);
              }
            }
            result = newArr;
          }
        }
      }
      this.dataSource.data = result;
    }).catch(() => {
      this.dataSource.data = [];
    });
  }

  ngOnInit(): void {
    this.refresh()
    this.subFilterControl = this.filterControl.valueChanges.pipe(debounceTime(500)).subscribe((value: string) => {
      this.refresh(value);
    });
  }

  ngOnDestroy(): void {
    if (this.subFilterControl != null) {
      this.subFilterControl.unsubscribe();
      this.subFilterControl = null;
    }
  }

  ngAfterViewInit() {
    this.dataSource.paginator = this.paginator;
  }

  add(): void {
    const dialogRef = this.dialog.open(EditBlacklistComponent, {
      width: '250px',
      data: null
    });

    firstValueFrom(dialogRef.afterClosed()).then(result => {
      console.log('The dialog was closed');
      console.log(result);
      if (result != null) {
        firstValueFrom(this.blacklistService.create(result)).then((res: IResult) => {
          if (!res.result) {
            this.toast.add({ severity: 'warn', summary: 'Что-то не так...', detail: 'Не удалось добавить домен' });
          } else {
            this.toast.add({ severity: 'success', summary: 'Успех', detail: 'Домен добавлен' });
          }
        });
      }
    });
  }

  delete(domain: string) {
    firstValueFrom(this.blacklistService.remove(domain)).then((result: IResult) => {
      if (result.result) {
        this.refresh();
        this.toast.add({ severity: 'success', summary: 'Успех' })
      }
    })
  }
}

Это компонент для работы с черным списком. По сути мы просто обрабатываем события нажатия кнопок.

Ко всему прочему в этом компоненте открывается диалоговое окно. Само окно реализуется просто (в другом компоненте):


import { Component, Inject, OnInit } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';

@Component({
  selector: 'app-edit',
  templateUrl: './edit-blacklist.component.html',
  styleUrls: ['./edit-blacklist.component.scss']
})
export class EditBlacklistComponent implements OnInit {

  form: FormGroup = new FormGroup({
    domain: new FormControl()
  });

  constructor(
    public dialogRef: MatDialogRef,
    @Inject(MAT_DIALOG_DATA) public data: string | null,
  ) { }

  ngOnInit(): void {
  }

  onCancel(): void {
    this.data = null;
    this.dialogRef.close(null);
  }

  onCreate(): void {
    this.dialogRef.close(this.form.value.domain);
  }

}

Модули

Чтобы Material правильно работал, нужно добавить используемые модули. Для этого создаем 2 модуля (так удобнее будет ими управлять):


npx ng g m modules/material
npx ng g m modules/primeng

material.module.ts:


import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FlexLayoutModule } from '@angular/flex-layout';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

import { MatToolbarModule } from '@angular/material/toolbar';
import { MatInputModule } from '@angular/material/input';
import { MatCardModule } from '@angular/material/card';
import { MatMenuModule } from '@angular/material/menu';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatDividerModule } from '@angular/material/divider';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSelectModule } from '@angular/material/select';
import { MatOptionModule } from '@angular/material/core';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatListModule } from '@angular/material/list';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatDialogModule } from '@angular/material/dialog';

const modules = [
  CommonModule,
  FlexLayoutModule,
  FormsModule,
  ReactiveFormsModule,
  BrowserAnimationsModule,
  MatToolbarModule,
  MatInputModule,
  MatCardModule,
  MatMenuModule,
  MatIconModule,
  MatButtonModule,
  MatTableModule,
  MatDividerModule,
  MatSlideToggleModule,
  MatSelectModule,
  MatOptionModule,
  MatProgressSpinnerModule,
  MatSidenavModule,
  MatListModule,
  MatPaginatorModule,
  MatSortModule,
  MatDialogModule
];

@NgModule({
  declarations: [],
  imports: modules,
  exports: modules
})
export class MaterialModule { }

Соответственно для PrimeNG добавляем те модули, которыми будем пользоваться.

Далее переходим в app.module.ts и добавляем модули:


imports: [
    BrowserModule,
    AppRoutingModule,
    BrowserAnimationsModule,
    MaterialModule,
    PrimengModule,
    ReactiveFormsModule,
    FormsModule,
    HttpClientModule
  ],

Так же добавим 2 провайдера:


providers: [
    {
      provide: HTTP_INTERCEPTORS,
      useClass: InterceptorInterceptor,
      multi: true,
    },
    MessageService
  ]
,

Ну вот и все

Получился такой простенький web-интерфейс. Полный код находится в git-репозитории.

Поделиться
Вы можете оставить комментарий, или ссылку на Ваш сайт.

Оставить комментарий

Вы должны быть авторизованы, чтобы разместить комментарий.