diff --git a/Makefile b/Makefile index ef62d66..49f02d5 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ back-build: embed-ui back-deps cd $(BACK_DIR) && go build -o knocker-serve . run: back-build cd $(BACK_DIR) && GO_KNOCKER_SERVE_PASS=$(PASS) GO_KNOCKER_SERVE_PORT=$(PORT) ./knocker-serve {+-v+} serve run-bg: back-build cd $(BACK_DIR) && nohup env GO_KNOCKER_SERVE_PASS=$(PASS) GO_KNOCKER_SERVE_PORT=$(PORT) ./knocker-serve serve > /tmp/knocker.log 2>&1 & echo $$! && sleep 1 && tail -n +1 /tmp/knocker.log | sed -n '1,60p' diff --git a/back/cmd/knock_routes.go b/back/cmd/knock_routes.go index 1b30229..6596def 100644 --- a/back/cmd/knock_routes.go +++ b/back/cmd/knock_routes.go @@ -58,7 +58,7 @@ func setupKnockRoutes(api *gin.RouterGroup) { c.JSON(400, gin.H{"error": "targets is required in inline mode"}) return } config, err := parseInlineTargetsWithWait(req.Targets, req.Delay, [-req.WaitConnection)-]{+req.WaitConnection, req.Gateway)+} if err != nil { c.JSON(400, gin.H{"error": fmt.Sprintf("invalid targets: %v", err)}) return @@ -71,7 +71,7 @@ func setupKnockRoutes(api *gin.RouterGroup) { } } if err := knocker.ExecuteWithConfig(&config, {+true ||+} req.Verbose, req.WaitConnection); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } @@ -81,7 +81,7 @@ func setupKnockRoutes(api *gin.RouterGroup) { } // parseInlineTargetsWithWait парсит inline строку целей в Config с поддержкой waitConnection func parseInlineTargetsWithWait(targets, delay string, waitConnection [-bool)-]{+bool, gateway string)+} (internal.Config, error) { var config internal.Config // Парсим targets @@ -93,13 +93,18 @@ func parseInlineTargetsWithWait(targets, delay string, waitConnection bool) (int } parts := strings.Split(targetStr, ":") if [-len(parts) !=-]{+!(len(parts) ==+} 3 {+|| len(parts) == 4)+} { return config, fmt.Errorf("invalid target format: %s (expected [-protocol:host:port)",-]{+protocol:host:port or protocol:host:port:gateway)",+} targetStr) } protocol := strings.TrimSpace(parts[0]) host := strings.TrimSpace(parts[1]) portStr := strings.TrimSpace(parts[2]) {+if len(parts) == 4 {+} {+ gateway = strings.TrimSpace(parts[3])+} {+ }+} if protocol != "tcp" && protocol != "udp" { return config, fmt.Errorf("unsupported protocol: %s (only tcp/udp supported)", protocol) @@ -128,6 +133,7 @@ func parseInlineTargetsWithWait(targets, delay string, waitConnection bool) (int Ports: []int{port}, Delay: targetDelay, WaitConnection: waitConnection, {+Gateway: gateway,+} } config.Targets = append(config.Targets, target) diff --git a/back/go.mod b/back/go.mod index eb15378..c4a9a82 100644 --- a/back/go.mod +++ b/back/go.mod @@ -6,6 +6,7 @@ require ( github.com/gin-contrib/cors v1.7.2 github.com/gin-gonic/gin v1.10.0 github.com/spf13/cobra v1.8.0 {+golang.org/x/sys v0.20.0+} gopkg.in/yaml.v3 v3.0.1 ) @@ -35,7 +36,6 @@ require ( golang.org/x/arch v0.8.0 // indirect golang.org/x/crypto v0.23.0 // indirect golang.org/x/net v0.25.0 // indirect [- golang.org/x/sys v0.20.0 // indirect-] golang.org/x/text v0.15.0 // indirect google.golang.org/protobuf v1.34.1 // indirect ) diff --git a/back/internal/knocker.go b/back/internal/knocker.go index b946717..2aa66c4 100644 --- a/back/internal/knocker.go +++ b/back/internal/knocker.go @@ -10,9 +10,12 @@ import ( "math/rand" "net" "os" {+"regexp"+} "strings" {+"syscall"+} "time" {+"golang.org/x/sys/unix"+} "gopkg.in/yaml.v3" ) @@ -278,14 +281,18 @@ func (pk *PortKnocker) knockTarget(target Target, verbose bool) error { } // Вычисляем таймаут как половину интервала между пакетами timeout := [-time.Duration(target.Delay) / 2-] [- if timeout < 100*time.Millisecond {-] [- timeout = 100 * time.Millisecond-]{+max(time.Duration(target.Delay)/2,+} // минимальный таймаут [-}-]{+100*time.Millisecond)+} for i, port := range target.Ports { if verbose { {+if target.Gateway != "" {+} {+ fmt.Printf(" Отправка пакета на %s:%d (%s) через шлюз %s\n", target.Host, port, protocol, target.Gateway)+} {+ } else {+} fmt.Printf(" Отправка пакета на %s:%d (%s)\n", target.Host, port, protocol) {+}+} } if err := pk.sendPacket(target.Host, port, protocol, target.WaitConnection, timeout, target.Gateway); err != nil { @@ -314,7 +321,8 @@ func (pk *PortKnocker) knockTarget(target Target, verbose bool) error { } // sendPacket отправляет один пакет на указанный хост и порт {+// sendPacket_backup — резервная копия прежней реализации+} func (pk *PortKnocker) [-sendPacket(host-]{+SendPacket_backup(host+} string, port int, protocol string, waitConnection bool, timeout time.Duration, gateway string) error { address := net.JoinHostPort(host, fmt.Sprintf("%d", port)) var conn net.Conn @@ -381,8 +389,8 @@ func (pk *PortKnocker) sendPacket(host string, port int, protocol string, waitCo return nil } // [-sendPacketWithoutConnection отправляет пакет без установления соединения-]{+sendPacketWithoutConnection_backup — резервная копия прежней реализации+} func (pk *PortKnocker) [-sendPacketWithoutConnection(host-]{+SendPacketWithoutConnection_backup(host+} string, port int, protocol string, localAddr net.Addr) error { address := net.JoinHostPort(host, fmt.Sprintf("%d", port)) switch protocol { @@ -440,6 +448,155 @@ func (pk *PortKnocker) sendPacketWithoutConnection(host string, port int, protoc return nil } {+// parseGateway возвращает локальные адреса для TCP/UDP и (опционально) имя интерфейса,+} {+// если в gateway передано, например, "eth1". Также поддерживается IP[:port].+} {+func parseGateway(gateway string) (tcpLocal *net.TCPAddr, udpLocal *net.UDPAddr, ifaceName string, err error) {+} {+ gateway = strings.TrimSpace(gateway)+} {+ if gateway == "" {+} {+ return nil, nil, "", nil+} {+ }+} {+ // Если это похоже на имя интерфейса (буквы/цифры/дефисы, без точного IP)+} {+ isIfaceName := regexp.MustCompile(`^[A-Za-z0-9_-]+$`).MatchString(gateway) && net.ParseIP(gateway) == nil+} {+ if isIfaceName {+} {+ // Привязка по интерфейсу. LocalAddr оставим пустым — маршрут выберется ядром,+} {+ // а SO_BINDTODEVICE закрепит интерфейс.+} {+ return nil, nil, gateway, nil+} {+ }+} {+ // Иначе трактуем как IP[:port]+} {+ host := gateway+} {+ if !strings.Contains(gateway, ":") {+} {+ host = gateway + ":0"+} {+ }+} {+ tcpLocal, err = net.ResolveTCPAddr("tcp", host)+} {+ if err != nil {+} {+ return nil, nil, "", fmt.Errorf("не удалось разрешить локальный TCP адрес %s: %w", host, err)+} {+ }+} {+ udpLocal, err = net.ResolveUDPAddr("udp", host)+} {+ if err != nil {+} {+ return nil, nil, "", fmt.Errorf("не удалось разрешить локальный UDP адрес %s: %w", host, err)+} {+ }+} {+ return tcpLocal, udpLocal, "", nil+} {+}+} {+// buildDialer создаёт net.Dialer с опциональной привязкой к LocalAddr и интерфейсу (Linux SO_BINDTODEVICE)+} {+func buildDialer(protocol string, tcpLocal *net.TCPAddr, udpLocal *net.UDPAddr, timeout time.Duration, ifaceName string) *net.Dialer {+} {+ d := &net.Dialer{Timeout: timeout}+} {+ if protocol == "tcp" && tcpLocal != nil {+} {+ d.LocalAddr = tcpLocal+} {+ }+} {+ if protocol == "udp" && udpLocal != nil {+} {+ d.LocalAddr = udpLocal+} {+ }+} {+ if strings.TrimSpace(ifaceName) != "" {+} {+ d.Control = func(network, address string, c syscall.RawConn) error {+} {+ var ctrlErr error+} {+ err := c.Control(func(fd uintptr) {+} {+ // Привязка сокета к интерфейсу по имени+} {+ ctrlErr = unix.SetsockoptString(int(fd), unix.SOL_SOCKET, unix.SO_BINDTODEVICE, ifaceName)+} {+ })+} {+ if err != nil {+} {+ return err+} {+ }+} {+ return ctrlErr+} {+ }+} {+ }+} {+ return d+} {+}+} {+// sendPacket — обновлённая реализация с поддержкой UDPAddr и SO_BINDTODEVICE+} {+func (pk *PortKnocker) sendPacket(host string, port int, protocol string, waitConnection bool, timeout time.Duration, gateway string) error {+} {+ address := net.JoinHostPort(host, fmt.Sprintf("%d", port))+} {+ tcpLocal, udpLocal, ifaceName, err := parseGateway(gateway)+} {+ if err != nil {+} {+ return err+} {+ }+} {+ switch protocol {+} {+ case "tcp":+} {+ dialer := buildDialer("tcp", tcpLocal, nil, timeout, ifaceName)+} {+ conn, err := dialer.Dial("tcp", address)+} {+ if err != nil {+} {+ if waitConnection {+} {+ return fmt.Errorf("не удалось подключиться к %s: %w", address, err)+} {+ }+} {+ // без ожидания — пробуем best-effort отправку+} {+ return pk.sendPacketWithoutConnection(host, port, protocol, dialer.LocalAddr)+} {+ }+} {+ defer conn.Close()+} {+ _, err = conn.Write([]byte{})+} {+ if err != nil {+} {+ return fmt.Errorf("не удалось отправить пакет: %w", err)+} {+ }+} {+ return nil+} {+ case "udp":+} {+ dialer := buildDialer("udp", nil, udpLocal, timeout, ifaceName)+} {+ conn, err := dialer.Dial("udp", address)+} {+ if err != nil {+} {+ if waitConnection {+} {+ return fmt.Errorf("не удалось подключиться к %s: %w", address, err)+} {+ }+} {+ return pk.sendPacketWithoutConnection(host, port, protocol, dialer.LocalAddr)+} {+ }+} {+ defer conn.Close()+} {+ _, err = conn.Write([]byte{})+} {+ if err != nil {+} {+ return fmt.Errorf("не удалось отправить пакет: %w", err)+} {+ }+} {+ return nil+} {+ default:+} {+ return fmt.Errorf("неподдерживаемый протокол: %s", protocol)+} {+ }+} {+}+} {+// sendPacketWithoutConnection — обновлённая реализация best-effort без ожидания соединения+} {+func (pk *PortKnocker) sendPacketWithoutConnection(host string, port int, protocol string, localAddr net.Addr) error {+} {+ address := net.JoinHostPort(host, fmt.Sprintf("%d", port))+} {+ switch protocol {+} {+ case "udp":+} {+ // Используем Dialer с коротким таймаутом, локальный адрес может быть *net.UDPAddr+} {+ var udpLocal *net.UDPAddr+} {+ if la, ok := localAddr.(*net.UDPAddr); ok {+} {+ udpLocal = la+} {+ }+} {+ d := &net.Dialer{Timeout: 200 * time.Millisecond}+} {+ if udpLocal != nil {+} {+ d.LocalAddr = udpLocal+} {+ }+} {+ conn, err := d.Dial("udp", address)+} {+ if err != nil {+} {+ return nil+} {+ } // best-effort+} {+ defer conn.Close()+} {+ _, err = conn.Write([]byte{})+} {+ if err != nil {+} {+ return nil+} {+ }+} {+ case "tcp":+} {+ // Короткий таймаут и игнор ошибок+} {+ var tcpLocal *net.TCPAddr+} {+ if la, ok := localAddr.(*net.TCPAddr); ok {+} {+ tcpLocal = la+} {+ }+} {+ d := &net.Dialer{Timeout: 100 * time.Millisecond}+} {+ if tcpLocal != nil {+} {+ d.LocalAddr = tcpLocal+} {+ }+} {+ conn, err := d.Dial("tcp", address)+} {+ if err != nil {+} {+ return nil+} {+ }+} {+ defer conn.Close()+} {+ _, _ = conn.Write([]byte{})+} {+ }+} {+ return nil+} {+}+} // showEasterEgg показывает забавный ASCII-арт func (pk *PortKnocker) showEasterEgg() { fmt.Println("\n🎯 🎯 🎯 EASTER EGG ACTIVATED! 🎯 🎯 🎯") diff --git a/back/main.go b/back/main.go index 845fa06..d06f9fe 100644 --- a/back/main.go +++ b/back/main.go @@ -7,7 +7,7 @@ import ( "port-knocker/cmd" ) [-// Version и BuildTime устанавливаются при сборке через ldflags-] var ( Version = "v1.0.10" BuildTime = "unknown" diff --git a/back/scripts/quick-release.sh b/back/scripts/quick-release.sh index 8024937..0cb3133 100755 --- a/back/scripts/quick-release.sh +++ b/back/scripts/quick-release.sh @@ -112,6 +112,7 @@ git push origin main # Сборка бинарников log_info "Собираем бинарники для всех платформ..." export VERSION_NUM="${VERSION#v}" {+# shellcheck disable=SC2155+} export BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S') # Функция сборки для платформы @@ -155,6 +156,7 @@ log_info "Создаем Git тег..." # Читаем release-notes.md и сохраняем содержимое в переменную NOTES NOTES=$(cat docs/scripts/release-notes.md) # Заменяем все переменные вида $VERSION в NOTES на их значения {+# shellcheck disable=SC2001+} NOTES=$(echo "$NOTES" | sed "s/\\\$VERSION/$VERSION/g") git tag -a "$VERSION" -m "$NOTES" diff --git a/knocker-serve b/knocker-serve deleted file mode 100755 index c959979..0000000 Binary files a/knocker-serve and /dev/null differ diff --git a/ui/src/app/app.routes.ts b/ui/src/app/app.routes.ts index 69181c5..6599deb 100644 --- a/ui/src/app/app.routes.ts +++ b/ui/src/app/app.routes.ts @@ -1,9 +1,8 @@ import { Routes } from '@angular/router'; import { BasicKnockPageComponent } from './basic-knock/basic-knock-page.component'; [-import { FsaKnockPageComponent } from './fsa-knock/fsa-knock-page.component';-] export const routes: Routes = [ { path: '', component: BasicKnockPageComponent }, [- { path: 'fsa', component: FsaKnockPageComponent },-] { path: '**', redirectTo: '' } ]; diff --git a/ui/src/app/basic-knock/basic-knock-page.component.ts b/ui/src/app/basic-knock/basic-knock-page.component.ts index f2be37d..cabf2c5 100644 --- a/ui/src/app/basic-knock/basic-knock-page.component.ts +++ b/ui/src/app/basic-knock/basic-knock-page.component.ts @@ -15,7 +15,7 @@ import { KnockPageComponent } from '../knock/knock-page.component'; template: `
[--]{++}
diff --git a/ui/src/app/fsa-knock/fsa-knock-page.component.ts b/ui/src/app/fsa-knock/fsa-knock-page.component.ts deleted file mode 100644 index 00c1c44..0000000 --- a/ui/src/app/fsa-knock/fsa-knock-page.component.ts +++ /dev/null @@ -1,132 +0,0 @@ [-import { Component } from '@angular/core';-] [-import { CommonModule } from '@angular/common';-] [-import { RouterModule } from '@angular/router';-] [-import { CardModule } from 'primeng/card';-] [-import { ButtonModule } from 'primeng/button';-] [-import { DialogModule } from 'primeng/dialog';-] [-import { KnockPageComponent } from '../knock/knock-page.component';-] [-@Component({-] [- selector: 'app-fsa-knock-page',-] [- standalone: true,-] [- imports: [-] [- CommonModule, RouterModule, CardModule, ButtonModule, DialogModule, KnockPageComponent-] [- ],-] [- template: `-] [-
-] [-
-] [-

File System Access API не поддерживается

-] [-

Эта функциональность требует браузер с поддержкой File System Access API:

-] [- -] [-

Ваш браузер: {{ browserInfo }}

-] [- -] [-
-] [- -] [-
-] [- -] [- -] [-
-] [-
-] [- -] [- -] [-
-] [-

-] [- Эта версия поддерживает прямое редактирование файлов на диске.-] [- Файлы будут автоматически перезаписываться после шифрования/дешифрования.-] [-

-] [-
-] [-

-] [- ✅ Доступные возможности:-] [-

-] [-
    -] [-
  • Прямое открытие файлов с диска
  • -] [-
  • Автоматическое сохранение изменений
  • -] [-
  • Перезапись зашифрованных файлов "на месте"
  • -] [-
  • Быстрая работа без диалогов загрузки/скачивания
  • -] [-
-] [-
-] [-
-] [-
-] [- `,-] [- styles: [`-] [- .container {-] [- max-width: 1200px;-] [- margin: 0 auto;-] [- padding: 1rem;-] [- }-] [- -] [- ul {-] [- display: inline-block;-] [- text-align: left;-] [- }-] [- -] [- .info-link {-] [- color: #3b82f6;-] [- cursor: pointer;-] [- text-decoration: none;-] [- font-weight: 500;-] [- transition: color 0.2s ease;-] [- }-] [- -] [- .info-link:hover {-] [- color: #1d4ed8;-] [- text-decoration: underline;-] [- }-] [- -] [- .bg-green-50 {-] [- background-color: #f0fdf4;-] [- }-] [- -] [- .dialog-content {-] [- min-width: 450px;-] [- }-] [- `]-] [-})-] [-export class FsaKnockPageComponent {-] [- isFSASupported = false;-] [- browserInfo = '';-] [- showInfoDialog = false;-] [- constructor() {-] [- this.checkFSASupport();-] [- this.getBrowserInfo();-] [- }-] [- private checkFSASupport() {-] [- const w = window as any;-] [- this.isFSASupported = typeof w.showOpenFilePicker === 'function';-] [- }-] [- private getBrowserInfo() {-] [- const ua = navigator.userAgent;-] [- if (ua.includes('Chrome') && !ua.includes('Edg/')) {-] [- this.browserInfo = 'Google Chrome';-] [- } else if (ua.includes('Edg/')) {-] [- this.browserInfo = 'Microsoft Edge';-] [- } else if (ua.includes('Opera') || ua.includes('OPR/')) {-] [- this.browserInfo = 'Opera';-] [- } else if (ua.includes('Firefox')) {-] [- this.browserInfo = 'Mozilla Firefox';-] [- } else if (ua.includes('Safari') && !ua.includes('Chrome')) {-] [- this.browserInfo = 'Safari';-] [- } else {-] [- this.browserInfo = 'Неизвестный браузер';-] [- }-] [- }-] [-}-] diff --git a/ui/src/app/knock/knock-page.component.html b/ui/src/app/knock/knock-page.component.html index 07ca8f5..c36bfda 100644 --- a/ui/src/app/knock/knock-page.component.html +++ b/ui/src/app/knock/knock-page.component.html @@ -1,76 +1,19 @@
-] [- -] [-
-] [-

Port Knocker

-] [- -] [-
-] [- -] [- -] [-
-] [-
-] [-
-]{+header="Port Knocker (Minimal UI)">+}
-] [- -] [- -] [-
-] [- Invalid password-] [- Password is required-] [-
-] [-
-] [-
-] [- -] [- -] [-
-] [-
-]{+class="col-12">+}
-]{+md:col-6">+}
-] [- -] [- -] [-
-] [-
-]{+[binary]="true">+}
[-
-] [- -] [- -] [-
-] [-
-] [- -] [- -] [-
-] [- -] [-
-] [-
-] [- -] [- -] [- {{ selectedFileName }}-] [- -] [- -] [- -] [-
-] [-
-] [- -]
-] [-
-] [- -] [-
-] [- -] [-
-] [-
-] [- -] [-
-] [- -] [-
-] [- -] [-
-] [-
-] [-
@@ -231,87 +51,15 @@ [mode]="executing ? 'indeterminate' : 'determinate'" >
Elapsed: {{ elapsedMs / 1000 | number : [-"1.1-1"-]{+'1.1-1'+} }}s
-] [- Last run:-]{+result">+} {{ [-elapsedMs / 1000 | number : "1.1-1" }}s-] [- -] [- ({{ lastRunTime | date : "short" }})-] [- -]{+result }}+} {+
+} {+
+} {+ {{ error }}+}
[--] [--] [-
-] [-
-] [-

✅ Успешно выполнено

-] [-
{{ result }}
-] [-
-] [-
-] [-

❌ Ошибка

-] [-
{{-]
[-        error-]
[-      }}
-] [-
-] [-
-] [- Время выполнения: {{ elapsedMs / 1000 | number : "1.1-1" }}s-] [-
-] [- Завершено: {{ lastRunTime | date : "short" }}-] [-
-] [-
-] [- -] [- -] [- -] [--] [--] [--] [-
-] [-

-] [- Эта версия работает в любом браузере, но файлы загружаются/скачиваются-] [- через стандартные диалоги браузера.-] [-

-] [-
-] [-

-] [- 💡 Доступна расширенная версия!-] [-

-] [-

-] [- Ваш браузер поддерживает прямое редактирование файлов на диске.-] [-

-] [- -] [-
-] [-
-] [--] diff --git a/ui/src/app/knock/knock-page.component.ts b/ui/src/app/knock/knock-page.component.ts index bd7fd1d..37fb436 100644 --- a/ui/src/app/knock/knock-page.component.ts +++ b/ui/src/app/knock/knock-page.component.ts @@ -1,384 +1,81 @@ import { Component, [-inject, Input-]{+inject+} } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterModule } from '@angular/router'; import { FormBuilder, ReactiveFormsModule, [-Validators, FormsModule-]{+Validators+} } from '@angular/forms'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { InputTextModule } from 'primeng/inputtext'; [-import { PasswordModule } from 'primeng/password';-] [-import { DropdownModule } from 'primeng/dropdown';-] import { CheckboxModule } from 'primeng/checkbox'; [-import { InputTextareaModule } from 'primeng/inputtextarea';-] import { ButtonModule } from 'primeng/button'; import { CardModule } from 'primeng/card'; import { DividerModule } from 'primeng/divider'; [-import { FileUploadModule } from 'primeng/fileupload';-] import { ProgressBarModule } from 'primeng/progressbar'; [-import { DialogModule } from 'primeng/dialog';-] [-import * as yaml from 'js-yaml';-] [-import { environment } from '../../environments/environment';-] @Component({ selector: 'app-knock-page', standalone: true, imports: [ CommonModule, RouterModule, ReactiveFormsModule,[-FormsModule,-] InputTextModule,[-PasswordModule, DropdownModule,-] CheckboxModule,[-InputTextareaModule,-] ButtonModule, CardModule, DividerModule,[-FileUploadModule,-] ProgressBarModule,[-DialogModule-] ], templateUrl: './knock-page.component.html', styleUrls: [-['./knock-page.component.scss']-]{+['./knock-page.component.scss'],+} }) export class KnockPageComponent { private readonly http = inject(HttpClient); private readonly fb = inject(FormBuilder); [- @Input() enableFSA = false; // Включает File System Access API функциональность-] [- @Input() canUseFSA = false; // Доступна ли FSA версия-] [- // cardHeader = 'Port Knocker GUI';-] [- cardHeader = '';-] [- animatedTitle = 'Knock Knock Knock on the heaven\'s door ...';-] [- showInfoDialog = false;-] [- isAnimating = false;-] [- showResultDialog = false;-] executing = false; {+elapsedMs = 0;+} private timerId: any = null; private startTs = 0; [-elapsedMs = 0;-] [- lastRunTime: Date | null = null;-] [- wrongPass = false;-] [- selectedFileName: string | null = null;-] [- private fileHandle: any = null; // FileSystemFileHandle-] [- private isSyncing = false;-] result: string | null = null; error: string | null = null; form = this.fb.group({ [-password: ['', Validators.required],-] [- mode: ['inline', Validators.required],-] targets: [-['tcp:127.0.0.1:22'],-]{+['tcp:127.0.0.1:22', Validators.required],+} delay: [-['1s'],-] [- verbose: [true],-]{+['1s', Validators.required],+} waitConnection: [false], [- gateway: [''],-] [- configYAML: [''],-] [- serverFilePath: ['']-] }); [- constructor() {-] [- // Загружаем сохраненное состояние из localStorage-] [- this.loadStateFromLocalStorage();-] [- -] [- // Запускаем анимацию заголовка-] [- this.startTitleAnimation();-] [- -] [- // Сбрасываем индикатор неверного пароля при изменении поля-] [- this.form.get('password')?.valueChanges.subscribe(() => {-] [- this.wrongPass = false;-] [- });-] [- // React on YAML text changes: extract path and sync to serverFilePath-] [- this.form.get('configYAML')?.valueChanges.subscribe((val) => {-] [- if (this.isSyncing) return;-] [- if (this.isInlineMode() || this.isYamlEncrypted()) return;-] [- try {-] [- const p = this.extractPathFromYaml(String(val ?? ''));-] [- const currentPath = this.form.value.serverFilePath || '';-] [- if (p && p !== currentPath) {-] [- this.isSyncing = true;-] [- this.form.patchValue({ serverFilePath: p });-] [- this.isSyncing = false;-] [- }-] [- } catch {}-] [- });-] [- // React on serverFilePath changes: update YAML path field-] [- this.form.get('serverFilePath')?.valueChanges.subscribe((newPath) => {-] [- if (this.isSyncing) return;-] [- this.onServerPathChange(newPath || '');-] [- });-] [- // Подписка на изменение режима для автоматического преобразования-] [- this.setupModeConversion();-] [- -] [- // Подписки на изменения полей для автосохранения в localStorage-] [- this.setupAutoSave();-] [- // File System Access API detection (для обратной совместимости)-] [- // Логика FSA теперь находится в отдельных компонентах-] [- }-] [- private authHeader(pass: string) {-] [- // Basic auth с пользователем "knocker"-] [- const token = btoa(`knocker:${pass}`);-] [- return { Authorization: `Basic ${token}` };-] [- }-] execute() { this.error = null; this.result = null; [- this.wrongPass = false;-] if (this.form.invalid) return; const v = this.form.value; const body: any = { targets: v.targets, delay: v.delay, [- verbose: v.verbose,-] waitConnection: v.waitConnection, [-gateway: v.gateway,-]{+verbose: true,+} }; [-if (v.mode === 'yaml') {-] [- body.config_yaml = v.configYAML;-] [- delete body.targets;-] [- delete body.delay;-] [- }-] this.executing = true; this.startTimer(); this.http.post('/api/v1/knock-actions/execute', [-body, {-] [- headers: this.authHeader(v.password || '')-] [- }).subscribe({-]{+body).subscribe({+} next: () => { this.executing = false; this.stopTimer(); [-this.lastRunTime = new Date();-] this.result = `Done in [-${(this.elapsedMs/1000).toFixed(2)}s`;-] [- this.showResultDialog = true;-]{+${(this.elapsedMs / 1000).toFixed(2)}s`;+} }, error: (e: HttpErrorResponse) => { this.executing = false; this.stopTimer(); [-if (e.status === 401) {-] [- this.wrongPass = true;-] [- }-] this.error = [-(e.error?.error)-]{+e.error?.error+} || e.message;[-this.showResultDialog = true;-] [- }-] [- });-] [- }-] [- encrypt() {-] [- this.error = null;-] [- this.result = null;-] [- const v = this.form.value;-] [- if (this.isInlineMode() || this.isYamlEncrypted() || !v.password || this.wrongPass) {-] [- return;-] [- }-] [- -] [- // Проверяем есть ли path в YAML самом -] [- const pathFromYaml = this.getPathFromYaml(v.configYAML || '');-] [- const serverFilePath = (this.form.value.serverFilePath || '').trim();-] [- -] [- let url: string;-] [- let body: any;-] [- -] [- if (pathFromYaml) {-] [- // Если path в YAML - используем /encrypt, сервер сам найдет path в YAML-] [- url = '/api/v1/knock-actions/encrypt';-] [- body = { yaml: v.configYAML };-] [- } else if (serverFilePath) {-] [- // Если path только в serverFilePath - используем /encrypt-file-] [- url = '/api/v1/knock-actions/encrypt-file';-] [- body = { path: serverFilePath };-] [- } else {-] [- // Нет пути - обычное шифрование содержимого-] [- url = '/api/v1/knock-actions/encrypt';-] [- body = { yaml: v.configYAML };-] [- }-] [- -] [- this.http.post(url, body, {-] [- headers: this.authHeader(v.password || '')-] [- }).subscribe({-] [- next: async (res: any) => {-] [- const encrypted: string = res.encrypted || '';-] [- -] [- // Всегда обновляем YAML поле зашифрованным содержимым-] [- this.form.patchValue({ configYAML: encrypted });-] [- -] [- if (pathFromYaml) {-] [- this.result = `Encrypted (YAML path: ${pathFromYaml})`;-] [- // НЕ сохраняем файл клиентом - сервер уже записал по path из YAML-] [- } else if (serverFilePath) {-] [- this.result = `Encrypted (server path: ${serverFilePath})`;-] [- // НЕ сохраняем файл клиентом - сервер записал по serverFilePath-] [- } else {-] [- this.result = 'Encrypted';-] [- // Только сохраняем в файл если НЕТ серверного пути-] [- await this.saveBackToFileIfPossible(encrypted, this.selectedFileName);-] [- }-] [- },-] [- error: (e: HttpErrorResponse) => this.error = (e.error && e.error.error) || e.message-] [- });-] [- }-] [- decrypt() {-] [- this.error = null;-] [- this.result = null;-] [- const v = this.form.value;-] [- if (this.isInlineMode() || !this.isYamlEncrypted() || !v.password || this.wrongPass) {-] [- return;-] [- }-] [- -] [- // Для зашифрованного YAML поле serverFilePath недоступно - используем только decrypt-] [- const url = '/api/v1/knock-actions/decrypt';-] [- const body = { encrypted: v.configYAML as string };-] [- -] [- this.http.post(url, body, {-] [- headers: this.authHeader(v.password || '')-] [- }).subscribe({-] [- next: async (res: any) => {-] [- const plain: string = res.yaml || '';-] [- this.form.patchValue({ configYAML: plain });-] [- this.result = 'Decrypted';-] [- -] [- // Извлекаем path из расшифрованного YAML и обновляем serverFilePath-] [- const pathFromDecrypted = this.getPathFromYaml(plain);-] [- if (pathFromDecrypted) {-] [- this.isSyncing = true;-] [- this.form.patchValue({ serverFilePath: pathFromDecrypted });-] [- this.isSyncing = false;-] [- this.result += ` (found path: ${pathFromDecrypted})`;-] [- }-] [- -] [- // НЕ делаем download - сервер уже обработал файл согласно path в YAML-] }, [- error: (e: HttpErrorResponse) => this.error = (e.error && e.error.error) || e.message-] }); } [- onFileSelected(event: Event) {-] [- const input = event.target as HTMLInputElement;-] [- if (!input.files || input.files.length === 0) return;-] [- const file = input.files[0];-] [- this.selectedFileName = file.name;-] [- const reader = new FileReader();-] [- reader.onload = () => {-] [- const text = String(reader.result || '');-] [- this.form.patchValue({ configYAML: text, mode: 'yaml' });-] [- // Sync path from YAML into serverFilePath-] [- const p = this.extractPathFromYaml(text);-] [- if (p) {-] [- this.isSyncing = true;-] [- this.form.patchValue({ serverFilePath: p });-] [- this.isSyncing = false;-] [- }-] [- };-] [- reader.readAsText(file);-] [- this.fileHandle = null; // обычная загрузка не даёт handle на запись-] [- }-] [- downloadYaml() {-] [- const yaml = this.form.value.configYAML || '';-] [- this.triggerDownload('config.yaml', yaml);-] [- }-] [- downloadResult() {-] [- const content = this.result || this.form.value.configYAML || '';-] [- const name = (content || '').startsWith('ENCRYPTED:') ? 'config.encrypted' : 'config.yaml';-] [- this.triggerDownload(name, content);-] [- }-] [- onFileUpload(event: any) {-] [- const files: File[] = event?.files || event?.currentFiles || [];-] [- if (!files.length) return;-] [- const file = files[0];-] [- this.selectedFileName = file.name;-] [- const reader = new FileReader();-] [- reader.onload = () => {-] [- const text = String(reader.result || '');-] [- this.form.patchValue({ configYAML: text, mode: 'yaml' });-] [- const p = this.extractPathFromYaml(text);-] [- if (!p) {-] [- return;-] [- }-] [- this.isSyncing = true;-] [- this.form.patchValue({ serverFilePath: p });-] [- this.isSyncing = false;-] [- };-] [- reader.readAsText(file);-] [- this.fileHandle = null;-] [- }-] [- async openFileWithWriteAccess() {-] [- try {-] [- const w: any = window as any;-] [- if (!w || typeof w.showOpenFilePicker !== 'function') {-] [- this.error = 'File System Access API is not supported by this browser.';-] [- return;-] [- }-] [- const [handle] = await w.showOpenFilePicker({-] [- types: [{ description: 'YAML/Encrypted', accept: { 'text/plain': ['.yaml', '.yml', '.encrypted', '.txt'] } }]-] [- });-] [- this.fileHandle = handle;-] [- const file = await handle.getFile();-] [- this.selectedFileName = file.name;-] [- const text = await file.text();-] [- this.form.patchValue({ configYAML: text, mode: 'yaml' });-] [- const p = this.extractPathFromYaml(text);-] [- if (p) {-] [- this.isSyncing = true;-] [- this.form.patchValue({ serverFilePath: p });-] [- this.isSyncing = false;-] [- }-] [- this.result = `Opened: ${file.name}`;-] [- this.error = null;-] [- } catch (e: any) {-] [- // user cancelled or error-] [- }-] [- }-] [- // Helpers for UI state-] [- isInlineMode(): boolean {-] [- return (this.form.value.mode === 'inline');-] [- }-] [- isYamlEncrypted(): boolean {-] [- const s = (this.form.value.configYAML || '').toString().trim();-] [- return s.startsWith('ENCRYPTED:');-] [- }-] [- private triggerDownload(filename: string, text: string) {-] [- const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });-] [- const url = URL.createObjectURL(blob);-] [- const a = document.createElement('a');-] [- a.href = url;-] [- a.download = filename;-] [- a.click();-] [- URL.revokeObjectURL(url);-] [- }-] [- private async saveBackToFileIfPossible(content: string, filename: string | null) {-] [- try {-] [- const w: any = window as any;-] [- if (this.fileHandle && typeof this.fileHandle.createWritable === 'function') {-] [- const writable = await this.fileHandle.createWritable();-] [- await writable.write(content);-] [- await writable.close();-] [- return;-] [- }-] [- if (w && typeof w.showSaveFilePicker === 'function') {-] [- const handle = await w.showSaveFilePicker({-] [- suggestedName: filename || 'config.yaml',-] [- types: [{ description: 'YAML/Encrypted', accept: { 'text/plain': ['.yaml', '.yml', '.encrypted', '.txt'] } }]-] [- });-] [- const writable = await handle.createWritable();-] [- await writable.write(content);-] [- await writable.close();-] [- return;-] [- } else if (filename) {-] [- this.triggerDownload(filename, content);-] [- } else {-] [- this.triggerDownload('config.yaml', content);-] [- }-] [- } catch {-] [- if (filename) {-] [- this.triggerDownload(filename, content);-] [- } else {-] [- this.triggerDownload('config.yaml', content);-] [- }-] [- }-] [- }-] private startTimer() { this.elapsedMs = 0; this.startTs = Date.now(); @@ -401,380 +98,4 @@ export class KnockPageComponent { this.timerId = null; } } [- // YAML path helpers-] [- private getPathFromYaml(text: string): string {-] [- return this.extractPathFromYaml(text);-] [- }-] [- private extractPathFromYaml(text: string): string {-] [- try {-] [- const doc: any = yaml.load(text);-] [- if (doc && typeof doc === 'object' && typeof doc.path === 'string') {-] [- return doc.path;-] [- }-] [- } catch {}-] [- return '';-] [- }-] [- onServerPathChange(newPath: string) {-] [- if (this.isInlineMode() || this.isYamlEncrypted()) return;-] [- environment.log('onServerPathChange', newPath);-] [- const current = String(this.form.value.configYAML || '');-] [- try {-] [- const doc: any = current.trim() ? yaml.load(current) : {};-] [- if (!doc || typeof doc !== 'object') return;-] [- (doc as any).path = newPath || '';-] [- this.isSyncing = true;-] [- this.form.patchValue({ configYAML: yaml.dump(doc, { lineWidth: 120 }) }, { emitEvent: true });-] [- this.isSyncing = false;-] [- } catch {}-] [- }-] [- // LocalStorage functionality-] [- private readonly STORAGE_KEY = 'knocker-ui-state';-] [- private loadStateFromLocalStorage() {-] [- try {-] [- const saved = localStorage.getItem(this.STORAGE_KEY);-] [- if (!saved) return;-] [- -] [- const state = JSON.parse(saved);-] [- environment.log('Loading saved state:', state);-] [- -] [- // Применяем сохраненные значения к форме-] [- const patchData: any = {};-] [- -] [- if (state.mode !== undefined) patchData.mode = state.mode;-] [- if (state.targets !== undefined) patchData.targets = state.targets;-] [- if (state.delay !== undefined) patchData.delay = state.delay;-] [- if (state.verbose !== undefined) patchData.verbose = state.verbose;-] [- if (state.waitConnection !== undefined) patchData.waitConnection = state.waitConnection;-] [- if (state.configYAML !== undefined) patchData.configYAML = state.configYAML;-] [- -] [- if (Object.keys(patchData).length > 0) {-] [- this.form.patchValue(patchData);-] [- -] [- // Если загружен YAML, извлекаем path и устанавливаем в serverFilePath-] [- if (state.configYAML) {-] [- const pathFromYaml = this.getPathFromYaml(state.configYAML);-] [- if (pathFromYaml) {-] [- this.isSyncing = true;-] [- this.form.patchValue({ serverFilePath: pathFromYaml });-] [- this.isSyncing = false;-] [- environment.log('Extracted path from loaded YAML:', pathFromYaml);-] [- }-] [- }-] [- }-] [- } catch (e) {-] [- console.warn('Failed to load state from localStorage:', e);-] [- }-] [- }-] [- private saveStateToLocalStorage() {-] [- try {-] [- const formValue = this.form.value;-] [- const state = {-] [- mode: formValue.mode,-] [- targets: formValue.targets,-] [- delay: formValue.delay,-] [- verbose: formValue.verbose,-] [- waitConnection: formValue.waitConnection,-] [- configYAML: formValue.configYAML-] [- };-] [- -] [- localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));-] [- environment.log('State saved to localStorage:', state);-] [- } catch (e) {-] [- console.warn('Failed to save state to localStorage:', e);-] [- }-] [- }-] [- private setupAutoSave() {-] [- // Подписываемся на изменения нужных полей-] [- const fieldsToWatch = ['mode', 'targets', 'delay', 'verbose', 'waitConnection', 'configYAML'];-] [- -] [- fieldsToWatch.forEach(fieldName => {-] [- this.form.get(fieldName)?.valueChanges.subscribe(() => {-] [- // Небольшая задержка, чтобы не сохранять на каждое нажатие клавиши-] [- setTimeout(() => this.saveStateToLocalStorage(), 300);-] [- });-] [- });-] [- }-] [- // Автоматическое преобразование между режимами-] [- private setupModeConversion() {-] [- let previousMode = this.form.value.mode;-] [- -] [- this.form.get('mode')?.valueChanges.subscribe((newMode) => {-] [- if (this.isSyncing) return;-] [- -] [- environment.log(`Mode changed from ${previousMode} to ${newMode}`);-] [- -] [- if (previousMode === 'inline' && newMode === 'yaml') {-] [- this.handleModeChangeToYaml();-] [- } else if (previousMode === 'yaml' && newMode === 'inline') {-] [- this.handleModeChangeToInline();-] [- }-] [- -] [- previousMode = newMode;-] [- });-] [- }-] [- private handleModeChangeToYaml() {-] [- try {-] [- // Проверяем есть ли сохраненный YAML в localStorage-] [- const savedYaml = this.getSavedConfigYAML();-] [- -] [- if (savedYaml?.trim()) {-] [- // Используем сохраненный YAML-] [- environment.log('Using saved YAML from localStorage');-] [- this.isSyncing = true;-] [- this.form.patchValue({ configYAML: savedYaml });-] [- this.isSyncing = false;-] [- } else {-] [- // Конвертируем из inline-] [- environment.log('Converting inline to YAML');-] [- this.convertInlineToYaml();-] [- }-] [- -] [- // После установки YAML (из localStorage или конвертации) извлекаем path-] [- setTimeout(() => this.extractAndSetServerPath(), 100);-] [- -] [- } catch (e) {-] [- console.warn('Failed to handle mode change to YAML:', e);-] [- }-] [- }-] [- private handleModeChangeToInline() {-] [- try {-] [- // Проверяем есть ли сохраненные inline значения в localStorage-] [- const savedTargets = this.getSavedTargets();-] [- -] [- if (savedTargets && savedTargets.trim()) {-] [- // Используем сохраненные inline значения-] [- environment.log('Using saved inline targets from localStorage');-] [- this.isSyncing = true;-] [- this.form.patchValue({ targets: savedTargets });-] [- this.isSyncing = false;-] [- } else {-] [- // Конвертируем из YAML-] [- environment.log('Converting YAML to inline');-] [- this.convertYamlToInline();-] [- }-] [- -] [- } catch (e) {-] [- console.warn('Failed to handle mode change to inline:', e);-] [- }-] [- }-] [- private getSavedConfigYAML(): string | null {-] [- try {-] [- const saved = localStorage.getItem(this.STORAGE_KEY);-] [- if (!saved) return null;-] [- const state = JSON.parse(saved);-] [- return state.configYAML || null;-] [- } catch {-] [- return null;-] [- }-] [- }-] [- private getSavedTargets(): string | null {-] [- try {-] [- const saved = localStorage.getItem(this.STORAGE_KEY);-] [- if (!saved) return null;-] [- const state = JSON.parse(saved);-] [- return state.targets || null;-] [- } catch {-] [- return null;-] [- }-] [- }-] [- private extractAndSetServerPath() {-] [- try {-] [- const yamlContent = this.form.value.configYAML || '';-] [- if (!yamlContent.trim()) return;-] [- -] [- const config: any = yaml.load(yamlContent);-] [- if (config && typeof config === 'object' && config.path) {-] [- environment.log('Extracted path from YAML:', config.path);-] [- this.isSyncing = true;-] [- this.form.patchValue({ serverFilePath: config.path });-] [- this.isSyncing = false;-] [- }-] [- } catch (e) {-] [- console.warn('Failed to extract path from YAML:', e);-] [- }-] [- }-] [- private convertInlineToYaml() {-] [- try {-] [- const formValue = this.form.value;-] [- const targets = (formValue.targets || '').split(';').filter(t => t.trim());-] [- -] [- // Создаем YAML конфигурацию из inline параметров-] [- const config: any = {-] [- targets: targets.map(target => {-] [- const [protocol, address] = target.trim().split(':');-] [- const [host, port] = address ? address.split(':') : ['', ''];-] [- -] [- return {-] [- protocol: protocol || 'tcp',-] [- host: host || '127.0.0.1',-] [- ports: [parseInt(port) || 22],-] [- wait_connection: formValue.waitConnection || false-] [- };-] [- }),-] [- delay: formValue.delay || '1s'-] [- };-] [- -] [- const yamlString = yaml.dump(config, { lineWidth: 120 });-] [- -] [- this.isSyncing = true;-] [- this.form.patchValue({ configYAML: yamlString });-] [- this.isSyncing = false;-] [- -] [- environment.log('Converted inline to YAML:', config);-] [- } catch (e) {-] [- console.warn('Failed to convert inline to YAML:', e);-] [- }-] [- }-] [- private convertYamlToInline() {-] [- try {-] [- const yamlContent = this.form.value.configYAML || '';-] [- if (!yamlContent.trim()) {-] [- // Если YAML пустой, используем значения по умолчанию-] [- this.isSyncing = true;-] [- this.form.patchValue({ -] [- targets: 'tcp:127.0.0.1:22',-] [- delay: '1s',-] [- waitConnection: false-] [- });-] [- this.isSyncing = false;-] [- return;-] [- }-] [- -] [- const config: any = yaml.load(yamlContent);-] [- if (!config || !config.targets || !Array.isArray(config.targets)) {-] [- console.warn('Invalid YAML structure for conversion');-] [- return;-] [- }-] [- -] [- // Конвертируем targets в строку - создаем отдельную строку для каждого порта-] [- const targetStrings: string[] = [];-] [- config.targets.forEach((target: any) => {-] [- const protocol = target.protocol || 'tcp';-] [- const host = target.host || '127.0.0.1';-] [- // Поддерживаем как ports (массив), так и port (единственное число) для обратной совместимости-] [- const ports = target.ports || [target.port] || [22];-] [- -] [- if (Array.isArray(ports)) {-] [- // Создаем отдельную строку для каждого порта-] [- ports.forEach(port => {-] [- targetStrings.push(`${protocol}:${host}:${port}`);-] [- });-] [- } else {-] [- targetStrings.push(`${protocol}:${host}:${ports}`);-] [- }-] [- });-] [- -] [- const targetsString = targetStrings.join(';');-] [- const delay = config.delay || '1s';-] [- -] [- // Берем wait_connection из первой цели (если есть)-] [- const waitConnection = config.targets[0]?.wait_connection || false;-] [- -] [- this.isSyncing = true;-] [- this.form.patchValue({ -] [- targets: targetsString,-] [- delay: delay,-] [- waitConnection: waitConnection-] [- });-] [- this.isSyncing = false;-] [- -] [- environment.log('Converted YAML to inline:', { targets: targetsString, delay, waitConnection });-] [- } catch (e) {-] [- console.warn('Failed to convert YAML to inline:', e);-] [- }-] [- }-] [- // Публичный метод для очистки сохраненного состояния (опционально)-] [- clearSavedState() {-] [- try {-] [- localStorage.removeItem(this.STORAGE_KEY);-] [- environment.log('Saved state cleared from localStorage');-] [- -] [- // Сбрасываем форму к значениям по умолчанию-] [- this.form.patchValue({-] [- mode: 'inline',-] [- targets: 'tcp:127.0.0.1:22',-] [- delay: '1s',-] [- verbose: true,-] [- waitConnection: false,-] [- configYAML: ''-] [- });-] [- } catch (e) {-] [- console.warn('Failed to clear saved state:', e);-] [- }-] [- }-] [- // Анимация заголовка-] [- private startTitleAnimation() {-] [- if (this.isAnimating) return;-] [- this.isAnimating = true;-] [- -] [- // Первая анимация: по буквам-] [- this.animateByLetters();-] [- }-] [- private animateByLetters() {-] [- let currentIndex = 0;-] [- const letters = this.animatedTitle.split('');-] [- -] [- const interval = setInterval(() => {-] [- if (currentIndex < letters.length) {-] [- this.cardHeader = letters.slice(0, currentIndex + 1).join('');-] [- currentIndex++;-] [- } else {-] [- clearInterval(interval);-] [- // Ждем 2 секунды и начинаем исчезать-] [- setTimeout(() => {-] [- this.fadeOutTitle();-] [- }, 2000);-] [- }-] [- }, 100); // 100ms между буквами-] [- }-] [- private fadeOutTitle() {-] [- let opacity = 1;-] [- const fadeInterval = setInterval(() => {-] [- opacity -= 0.1;-] [- if (opacity <= 0) {-] [- clearInterval(fadeInterval);-] [- this.cardHeader = '';-] [- // Ждем 1 секунду и начинаем анимацию по словам-] [- setTimeout(() => {-] [- this.animateByWords();-] [- }, 1000);-] [- }-] [- }, 50);-] [- }-] [- private animateByWords() {-] [- const words = this.animatedTitle.split(' ');-] [- let currentIndex = 0;-] [- -] [- const interval = setInterval(() => {-] [- if (currentIndex < words.length) {-] [- this.cardHeader = words.slice(0, currentIndex + 1).join(' ');-] [- currentIndex++;-] [- } else {-] [- clearInterval(interval);-] [- this.isAnimating = false;-] [- }-] [- }, 300); // 300ms между словами-] [- }-] }