From 0fa566c1652424162f9f8abeba1f4616feea0b07 Mon Sep 17 00:00:00 2001 From: Anton Kuznetcov Date: Mon, 22 Sep 2025 11:26:29 +0600 Subject: [PATCH] before form mode add --- article/embed-gui-guide.md | 4 +- back/cmd/knock_routes.go | 15 +- back/go.mod | 2 +- back/internal/knocker.go | 173 ++- diff | 1588 ++++++++++++++++++++ ui/src/app/knock/knock-page.component.html | 3 +- 6 files changed, 1768 insertions(+), 17 deletions(-) create mode 100644 diff diff --git a/article/embed-gui-guide.md b/article/embed-gui-guide.md index 9a50a15..2595d4a 100644 --- a/article/embed-gui-guide.md +++ b/article/embed-gui-guide.md @@ -29,8 +29,8 @@ version: 1.0.2 ## Введение -Допустим есть желание к консольной Go утилите прикрутить небольшой веб‑интерфейс, чтобы не гонять команды вручную в терминале или расширить круг пользователей. -Как вариант добавляем api в утилиту, делаем SPA на ангуляре, например, дальше кидаем в проект api скомпилированное spa, cобрали бинарь и отдаем статику прямо из Go‑бинарника (или из рядом лежащей папки). +Допустим есть желание к консольной Go утилите прикрутить небольшой веб‑интерфейс, чтобы не гонять команды вручную в терминале или расширить круг пользователей. +Как вариант добавляем api в утилиту, делаем SPA на ангуляре, например, дальше кидаем в проект api скомпилированное spa, cобрали бинарь и отдаем статику прямо из Go‑бинарника (или из рядом лежащей папки). В качестве утилиты берем go-knocker - утилиту чтобы постучаться по портам ( такая штука повышающая безопасность серверов и устройств). Проект утилиты можно найти тут: diff --git a/back/cmd/knock_routes.go b/back/cmd/knock_routes.go index 1b30229..83b4562 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) + config, err := parseInlineTargetsWithWait(req.Targets, req.Delay, 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, req.Verbose, req.WaitConnection); err != nil { + 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) (internal.Config, error) { +func parseInlineTargetsWithWait(targets, delay string, waitConnection bool, gateway string) (internal.Config, error) { var config internal.Config // Парсим targets @@ -93,14 +93,18 @@ func parseInlineTargetsWithWait(targets, delay string, waitConnection bool) (int } parts := strings.Split(targetStr, ":") - if len(parts) != 3 { - return config, fmt.Errorf("invalid target format: %s (expected protocol:host:port)", targetStr) + if !(len(parts) == 3 || len(parts) == 4) { + return config, fmt.Errorf("invalid target format: %s (expected 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 +132,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..eb83c7e 100644 --- a/back/go.mod +++ b/back/go.mod @@ -35,7 +35,7 @@ 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/sys v0.20.0 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 // минимальный таймаут - } + timeout := max(time.Duration(target.Delay)/2, + // минимальный таймаут + 100*time.Millisecond) for i, port := range target.Ports { if verbose { - fmt.Printf(" Отправка пакета на %s:%d (%s)\n", target.Host, port, protocol) + 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 отправляет один пакет на указанный хост и порт -func (pk *PortKnocker) sendPacket(host string, port int, protocol string, waitConnection bool, timeout time.Duration, gateway string) error { +// sendPacket_backup — резервная копия прежней реализации +func (pk *PortKnocker) 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 отправляет пакет без установления соединения -func (pk *PortKnocker) sendPacketWithoutConnection(host string, port int, protocol string, localAddr net.Addr) error { +// sendPacketWithoutConnection_backup — резервная копия прежней реализации +func (pk *PortKnocker) 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/diff b/diff new file mode 100644 index 0000000..c08a24c --- /dev/null +++ b/diff @@ -0,0 +1,1588 @@ +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:

-] +[-
    -] +[-
  • Google Chrome 86+
  • -] +[-
  • Microsoft Edge 86+
  • -] +[-
  • Opera 72+
  • -] +[-
-] +[-

Ваш браузер: {{ 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 между словами-] +[- }-] +} + + diff --git a/ui/src/app/knock/knock-page.component.html b/ui/src/app/knock/knock-page.component.html index 07ca8f5..30b4cb6 100644 --- a/ui/src/app/knock/knock-page.component.html +++ b/ui/src/app/knock/knock-page.component.html @@ -51,7 +51,8 @@ formControlName="mode" [options]="[ { label: 'Inline', value: 'inline' }, - { label: 'YAML', value: 'yaml' } + { label: 'YAML', value: 'yaml' }, + { label: 'Form', value: 'form' } ]" optionLabel="label" optionValue="value"