Files
knock-gui/diff
2025-09-22 11:26:29 +06:00

1589 lines
60 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: `
<div class="container">
<!-- Встраиваем основной компонент в базовом режиме -->
[-<app-knock-page [enableFSA]="false" [canUseFSA]="canUseFSA"></app-knock-page>-]{+<app-knock-page></app-knock-page>+}
</div>
<!-- Информационное модальное окно -->
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: `-]
[- <div class="container">-]
[- <div *ngIf="!isFSASupported" class="text-center">-]
[- <h3>File System Access API не поддерживается</h3>-]
[- <p>Эта функциональность требует браузер с поддержкой File System Access API:</p>-]
[- <ul class="text-left mt-3">-]
[- <li>Google Chrome 86+</li>-]
[- <li>Microsoft Edge 86+</li>-]
[- <li>Opera 72+</li>-]
[- </ul>-]
[- <p class="mt-3">Ваш браузер: <strong>{{ browserInfo }}</strong></p>-]
[- <button pButton -]
[- type="button" -]
[- label="Перейти к основной версии" -]
[- class="p-button-outlined mt-3"-]
[- routerLink="/">-]
[- </button>-]
[- </div>-]
[- -]
[- <div *ngIf="isFSASupported">-]
[- <!-- Встраиваем основной компонент с поддержкой FSA -->-]
[- <app-knock-page [enableFSA]="true" [canUseFSA]="true"></app-knock-page>-]
[- </div>-]
[- </div>-]
[- <!-- Информационное модальное окно -->-]
[- <p-dialog header="🚀 Расширенная версия с File System Access" -]
[- [(visible)]="showInfoDialog" -]
[- [modal]="true" -]
[- [closable]="true"-]
[- [draggable]="false"-]
[- [resizable]="false"-]
[- styleClass="info-dialog">-]
[- <div class="dialog-content">-]
[- <p class="mb-3">-]
[- Эта версия поддерживает прямое редактирование файлов на диске.-]
[- Файлы будут автоматически перезаписываться после шифрования/дешифрования.-]
[- </p>-]
[- <div class="p-3 bg-green-50 border-round">-]
[- <p class="text-sm mb-2">-]
[- ✅ <strong>Доступные возможности:</strong>-]
[- </p>-]
[- <ul class="text-sm mb-0">-]
[- <li>Прямое открытие файлов с диска</li>-]
[- <li>Автоматическое сохранение изменений</li>-]
[- <li>Перезапись зашифрованных файлов "на месте"</li>-]
[- <li>Быстрая работа без диалогов загрузки/скачивания</li>-]
[- </ul>-]
[- </div>-]
[- </div>-]
[- </p-dialog>-]
[- `,-]
[- 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 @@
<div class="container">
<p-card [-[header]="cardHeader">-]
[- <ng-template pTemplate="header">-]
[- <div class="flex justify-content-between align-items-center">-]
[- <h1 style="margin-left: 1rem">Port Knocker</h1>-]
[- <!-- <div class="animated-title" [class.animating]="isAnimating">-]
[- <span *ngIf="cardHeader">{{ cardHeader }}</span>-]
[- </div> -->-]
[- <div class="flex gap-2">-]
[- <button-]
[- *ngIf="!enableFSA"-]
[- pButton-]
[- type="button"-]
[- label="📁 Info"-]
[- class="p-button-text p-button-sm"-]
[- (click)="showInfoDialog = true"-]
[- ></button>-]
[- <button-]
[- *ngIf="canUseFSA && !enableFSA"-]
[- pButton-]
[- type="button"-]
[- label="🚀 FSA Version"-]
[- class="p-button-text p-button-sm"-]
[- routerLink="/fsa"-]
[- ></button>-]
[- </div>-]
[- </div>-]
[- </ng-template>-]{+header="Port Knocker (Minimal UI)">+}
<form [formGroup]="form" (ngSubmit)="execute()" class="p-fluid">
<div class="grid">
<div [-class="col-12 md:col-6">-]
[- <label>Password</label>-]
[- <p-password-]
[- formControlName="password"-]
[- [feedback]="false"-]
[- toggleMask-]
[- inputStyleClass="w-full"-]
[- placeholder="GO_KNOCKER_SERVE_PASS"-]
[- ></p-password>-]
[- <div class="mt-1 text-sm" *ngIf="!form.value.password || wrongPass">-]
[- <span class="text-red-500" *ngIf="wrongPass">Invalid password</span>-]
[- <span class="text-600" *ngIf="!wrongPass && !form.value.password"-]
[- >Password is required</span-]
[- >-]
[- </div>-]
[- </div>-]
[- <div class="col-12 md:col-6">-]
[- <label>Mode</label>-]
[- <p-dropdown-]
[- formControlName="mode"-]
[- [options]="[-]
[- { label: 'Inline', value: 'inline' },-]
[- { label: 'YAML', value: 'yaml' }-]
[- ]"-]
[- optionLabel="label"-]
[- optionValue="value"-]
[- class="w-full"-]
[- ></p-dropdown>-]
[- </div>-]
[- <div class="col-12" *ngIf="form.value.mode === 'inline'">-]{+class="col-12">+}
<label>Targets</label>
<input
pInputText
type="text"
formControlName="targets"
[-placeholder="tcp:host:port;udp:host:port"-]{+placeholder="tcp:host:port;udp:host:port;...;tcp:host:port"+}
class="w-full"
/>
</div>
<div class="col-12 [-md:col-4" *ngIf="form.value.mode === 'inline'">-]{+md:col-6">+}
<label>Delay</label>
<input
pInputText
@@ -81,83 +24,11 @@
/>
</div>
<div [-class="col-6 md:col-4 flex align-items-center gap-2">-]
[- <p-checkbox formControlName="verbose" [binary]="true"></p-checkbox>-]
[- <label class="checkbox-label">Verbose</label>-]
[- </div>-]
[- <div class="col-6 md:col-4-]{+class="col-12 md:col-6+} flex align-items-center gap-2">
<p-checkbox formControlName="waitConnection" [-[binary]="true"-]
[- ></p-checkbox>-]{+[binary]="true"></p-checkbox>+}
<label class="checkbox-label">Wait connection</label>
</div>
[- <div class="col-12">-]
[- <label>Gateway</label>-]
[- <input-]
[- pInputText-]
[- type="text"-]
[- formControlName="gateway"-]
[- placeholder="optional local ip:port"-]
[- class="w-full"-]
[- />-]
[- </div>-]
[- <div class="col-12" *ngIf="form.value.mode === 'yaml'">-]
[- <label>YAML</label>-]
[- <textarea-]
[- pInputTextarea-]
[- formControlName="configYAML"-]
[- rows="12"-]
[- placeholder="paste YAML or ENCRYPTED:"-]
[- class="w-full"-]
[- ></textarea>-]
[- </div>-]
[- <!-- File controls directly under YAML -->-]
[- <div class="col-12" *ngIf="form.value.mode === 'yaml'">-]
[- <div class="flex flex-wrap gap-2 align-items-center">-]
[- <!-- FSA version -->-]
[- <button-]
[- *ngIf="enableFSA"-]
[- pButton-]
[- type="button"-]
[- label="Open File (with write access)"-]
[- (click)="openFileWithWriteAccess()"-]
[- class="p-button-outlined"-]
[- ></button>-]
[- <span-]
[- *ngIf="enableFSA && selectedFileName"-]
[- class="text-sm text-600"-]
[- >{{ selectedFileName }}</span-]
[- >-]
[- <!-- Basic version -->-]
[- <p-fileUpload-]
[- *ngIf="!enableFSA"-]
[- mode="basic"-]
[- name="file"-]
[- chooseLabel="Choose File"-]
[- (onSelect)="onFileUpload($event)"-]
[- [customUpload]="true"-]
[- [auto]="false"-]
[- accept=".yaml,.yml,.txt,.encrypted"-]
[- [maxFileSize]="1048576"-]
[- ></p-fileUpload>-]
[- <input-]
[- *ngIf="!enableFSA && !isYamlEncrypted()"-]
[- pInputText-]
[- type="text"-]
[- class="w-full md:w-6"-]
[- placeholder="Server file path (optional)"-]
[- formControlName="serverFilePath"-]
[- />-]
[- </div>-]
[- </div>-]
[- <!-- Row 1: Execute full width -->-]
<div class="col-12">
<button
pButton
@@ -165,58 +36,7 @@
label="Execute"
class="w-full"
[loading]="executing"
[disabled]="executing || [-!form.value.password || wrongPass"-]
[- [ngClass]="{ 'p-button-danger': !form.value.password || wrongPass }"-]
[- ></button>-]
[- </div>-]
[- <!-- Row 2: Encrypt / Decrypt half/half on desktop; stacked on mobile (только для YAML режима) -->-]
[- <div class="col-12 md:col-6" *ngIf="!isInlineMode()">-]
[- <button-]
[- pButton-]
[- type="button"-]
[- label="Encrypt"-]
[- (click)="encrypt()"-]
[- class="p-button-secondary w-full"-]
[- [disabled]="-]
[- executing ||-]
[- !form.value.password ||-]
[- wrongPass ||-]
[- isYamlEncrypted()-]
[- "-]
[- ></button>-]
[- </div>-]
[- <div class="col-12 md:col-6" *ngIf="!isInlineMode()">-]
[- <button-]
[- pButton-]
[- type="button"-]
[- label="Decrypt"-]
[- (click)="decrypt()"-]
[- class="p-button-secondary w-full"-]
[- [disabled]="-]
[- executing ||-]
[- !form.value.password ||-]
[- wrongPass ||-]
[- !isYamlEncrypted()-]
[- "-]
[- ></button>-]
[- </div>-]
[- <!-- Row 3: Download actions half/half on desktop; stacked on mobile (только для YAML режима) -->-]
[- <div class="col-12 md:col-6" *ngIf="!isInlineMode()">-]
[- <button-]
[- pButton-]
[- type="button"-]
[- label="Download YAML"-]
[- (click)="downloadYaml()"-]
[- class="p-button-text w-full"-]
[- ></button>-]
[- </div>-]
[- <div class="col-12 md:col-6" *ngIf="!isInlineMode()">-]
[- <button-]
[- pButton-]
[- type="button"-]
[- label="Download Result"-]
[- (click)="downloadResult()"-]
[- class="p-button-text w-full"-]{+form.invalid"+}
></button>
</div>
</div>
@@ -231,87 +51,15 @@
[mode]="executing ? 'indeterminate' : 'determinate'"
></p-progressBar>
<div class="mt-2 text-600" *ngIf="executing">
Elapsed: {{ elapsedMs / 1000 | number : [-"1.1-1"-]{+'1.1-1'+} }}s
</div>
<div class="mt-2 text-600" *ngIf="!executing && [-elapsedMs > 0">-]
[- Last run:-]{+result">+}
{{ [-elapsedMs / 1000 | number : "1.1-1" }}s-]
[- <span *ngIf="lastRunTime" class="ml-2 text-500">-]
[- ({{ lastRunTime | date : "short" }})-]
[- </span>-]{+result }}+}
{+ </div>+}
{+ <div class="mt-2 text-red-600" *ngIf="!executing && error">+}
{+ {{ error }}+}
</div>
</div>
</div>
</p-card>
</div>
[-<!-- Модальное окно с результатом выполнения -->-]
[-<p-dialog-]
[- header="Результат выполнения"-]
[- [(visible)]="showResultDialog"-]
[- [modal]="true"-]
[- [closable]="true"-]
[- [draggable]="false"-]
[- [resizable]="false"-]
[- styleClass="result-dialog"-]
[->-]
[- <div class="dialog-content">-]
[- <div *ngIf="result" class="mb-3">-]
[- <h4 class="text-green-600 mb-2">✅ Успешно выполнено</h4>-]
[- <pre class="bg-gray-50 p-3 border-round text-sm">{{ result }}</pre>-]
[- </div>-]
[- <div *ngIf="error" class="mb-3">-]
[- <h4 class="text-red-600 mb-2">❌ Ошибка</h4>-]
[- <pre class="bg-red-50 p-3 border-round text-sm text-red-700">{{-]
[- error-]
[- }}</pre>-]
[- </div>-]
[- <div *ngIf="lastRunTime" class="text-sm text-600">-]
[- Время выполнения: {{ elapsedMs / 1000 | number : "1.1-1" }}s-]
[- <br />-]
[- Завершено: {{ lastRunTime | date : "short" }}-]
[- </div>-]
[- </div>-]
[- <ng-template pTemplate="footer">-]
[- <button-]
[- pButton-]
[- type="button"-]
[- label="OK"-]
[- class="p-button-primary"-]
[- (click)="showResultDialog = false"-]
[- ></button>-]
[- </ng-template>-]
[-</p-dialog>-]
[-<!-- Информационное модальное окно -->-]
[-<p-dialog-]
[- header="📁 Базовая версия"-]
[- [(visible)]="showInfoDialog"-]
[- [modal]="true"-]
[- [closable]="true"-]
[- [draggable]="false"-]
[- [resizable]="false"-]
[- styleClass="info-dialog"-]
[->-]
[- <div class="dialog-content">-]
[- <p class="mb-3">-]
[- Эта версия работает в любом браузере, но файлы загружаются/скачиваются-]
[- через стандартные диалоги браузера.-]
[- </p>-]
[- <div *ngIf="canUseFSA" class="p-3 bg-blue-50 border-round">-]
[- <p class="text-sm mb-2">-]
[- 💡 <strong>Доступна расширенная версия!</strong>-]
[- </p>-]
[- <p class="text-sm mb-3">-]
[- Ваш браузер поддерживает прямое редактирование файлов на диске.-]
[- </p>-]
[- <button-]
[- pButton-]
[- type="button"-]
[- label="Перейти к расширенной версии"-]
[- class="p-button-success p-button-sm"-]
[- routerLink="/fsa"-]
[- (click)="showInfoDialog = false"-]
[- ></button>-]
[- </div>-]
[- </div>-]
[-</p-dialog>-]
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 между словами-]
[- }-]
}