diff --git a/article/embed-gui-guide.md b/article/embed-gui-guide.md index 4284c23..9a50a15 100644 --- a/article/embed-gui-guide.md +++ b/article/embed-gui-guide.md @@ -1,10 +1,11 @@ # Встраиваем веб-GUI в консольную утилиту: практический гайд ```metadata -id: 10 +id: 2 +title: "Встраиваем веб-GUI в консольную утилиту: практический гайд" readTime: 15-20 минут date: 2025-09-10 18:00 -author: Direct-Dev (aka Антон Кузнецов) +author: Direct-Dev (Антон) level: Средний tags: #go #angular #spa #embed #static #cli #webui #devops version: 1.0.2 @@ -15,7 +16,7 @@ version: 1.0.2 - [Встраиваем веб-GUI в консольную утилиту: практический гайд](#встраиваем-веб-gui-в-консольную-утилиту-практический-гайд) - [Содержание](#содержание) - [Введение](#введение) - - [Как быстро повторить (клонируем, собираем, запускаем)](#как-быстро-повторить-клонируем-собираем-запускаем) + - [Клонируем, собираем, запускаем](#клонируем-собираем-запускаем) - [Структура проекта](#структура-проекта) - [Идея и архитектура](#идея-и-архитектура) - [Минимальный GUI](#минимальный-gui) @@ -28,18 +29,27 @@ version: 1.0.2 ## Введение -Давайте по‑простому. Хотим к консольной утилите прикрутить небольшой веб‑интерфейс, чтобы не гонять команды вручную. Собрали один раз — и отдаем статику прямо из Go‑бинарника (или из рядом лежащей папки). В примере оставим только самое нужное: +Допустим есть желание к консольной Go утилите прикрутить небольшой веб‑интерфейс, чтобы не гонять команды вручную в терминале или расширить круг пользователей. +Как вариант добавляем api в утилиту, делаем SPA на ангуляре, например, дальше кидаем в проект api скомпилированное spa, cобрали бинарь и отдаем статику прямо из Go‑бинарника (или из рядом лежащей папки). + +В качестве утилиты берем go-knocker - утилиту чтобы постучаться по портам ( такая штука повышающая безопасность серверов и устройств). Проект утилиты можно найти тут: + +в данном упрощенном интерфейсе используем только следующие поля и inline режим работы утилиты: + +на форме будут следующие поля - Targets (строка вида `tcp:host:port;udp:host:port...`) - Delay (например `1s`) - Флаг Wait connection - Кнопка Execute -Если нужен «боевой» вариант — всегда можно нарастить поля и логику. Но начнем с минимума. +Если нужен более «продвинутый» вариант — всегда можно нарастить поля и логику. -## Как быстро повторить (клонируем, собираем, запускаем) +## Клонируем, собираем, запускаем -1 Клонируем репозиторий и переключаемся на ветку для статьи: +Источник репозитория: [`https://direct-dev.ru/gitea/GiteaAdmin/knock-gui`](https://direct-dev.ru/gitea/GiteaAdmin/knock-gui) + +1 Клонируем репозиторий и переключаемся на ветку for-article: ```bash # HTTPS клон @@ -50,9 +60,7 @@ cd knock-gui git checkout for-article ``` -Источник репозитория: [`https://direct-dev.ru/gitea/GiteaAdmin/knock-gui`](https://direct-dev.ru/gitea/GiteaAdmin/knock-gui) - -2 Ставим зависимости фронта и собираем UI: +2 переходим в папку фронта, ставим зависимости и собираем UI: ```bash cd ui @@ -63,6 +71,7 @@ npm ci cd .. # или через make +cd .. make embed-ui ``` @@ -285,6 +294,7 @@ npx ng build --configuration production \ ``` Что это даёт: + - В `dist/.../browser/index.html` будет ``. - Все ссылки на бандлы/ассеты будут начинаться с `/ui/simple/`. @@ -298,12 +308,14 @@ npx ng build --configuration production \ Важно: закрывающий слеш обязателен, иначе роутинг может «ехать». Настройка бэкенда: -- Сервируйте содержимое собранной папки по маршруту `/ui/simple` (или скопируйте артефакты в `back/cmd/public/ui/simple/`). + +- Отгружайте содержимое собранной папки по маршруту `/ui/simple` (или скопируйте артефакты в `back/cmd/public/ui/simple/`). - SPA‑фоллбэк должен отдавать `index.html` при запросах внутри префикса, если это не файловые ресурсы. Если у вас универсальный обработчик (как в `setupStaticRoutes`) на корне, два простых подхода: -- Хранить файлы в `public/ui/simple/...` — тогда запросы к `/ui/simple/...` будут удовлетворены корректно. -- Либо сделать отдельный хендлер, который для путей с префиксом `/ui/simple` читает файлы из подкаталога, а на несуществующие файлы отвечает содержимым `public/ui/simple/index.html`. + +- Хранить файлы в `public/ui/simple/...` — тогда запросы к `/ui/simple/...` будут отработаны корректно. +- Сделать отдельный хендлер, который для путей с префиксом `/ui/simple` читает файлы из подкаталога, а на несуществующие файлы отвечает содержимым `public/ui/simple/index.html`. Пример маппинга структуры в `public/`: @@ -318,10 +330,6 @@ public/ └── ... ``` -Проверка: -```text -http://localhost:8888/ui/simple -``` Если при прямом заходе на вложенный роут `/ui/simple/some/child` видите 404 — значит фоллбэк не отрабатывает. Проверьте, что при отсутствии файла по этому пути сервер возвращает `public/ui/simple/index.html`. ## FAQ и типичные ошибки diff --git a/back/cmd/knock_routes.go b/back/cmd/knock_routes.go index e581941..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) + 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 @@ -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,13 +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 +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 b37d4fe..2aa66c4 100644 --- a/back/internal/knocker.go +++ b/back/internal/knocker.go @@ -287,7 +287,12 @@ func (pk *PortKnocker) knockTarget(target Target, verbose bool) error { 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 { diff --git a/ui/src/app/knock/knock-page.component.ts b/ui/src/app/knock/knock-page.component.ts index e69f814..37fb436 100644 --- a/ui/src/app/knock/knock-page.component.ts +++ b/ui/src/app/knock/knock-page.component.ts @@ -14,12 +14,18 @@ import { ProgressBarModule } from 'primeng/progressbar'; selector: 'app-knock-page', standalone: true, imports: [ - CommonModule, RouterModule, ReactiveFormsModule, - InputTextModule, CheckboxModule, ButtonModule, CardModule, - DividerModule, ProgressBarModule + CommonModule, + RouterModule, + ReactiveFormsModule, + InputTextModule, + CheckboxModule, + ButtonModule, + CardModule, + DividerModule, + ProgressBarModule, ], templateUrl: './knock-page.component.html', - styleUrls: ['./knock-page.component.scss'] + styleUrls: ['./knock-page.component.scss'], }) export class KnockPageComponent { private readonly http = inject(HttpClient); @@ -36,7 +42,7 @@ export class KnockPageComponent { form = this.fb.group({ targets: ['tcp:127.0.0.1:22', Validators.required], delay: ['1s', Validators.required], - waitConnection: [false] + waitConnection: [false], }); execute() { @@ -48,13 +54,14 @@ export class KnockPageComponent { const body: any = { targets: v.targets, delay: v.delay, - waitConnection: v.waitConnection + waitConnection: v.waitConnection, + verbose: true, }; this.executing = true; this.startTimer(); - // Без обязательного Basic-Auth: заголовок не добавляется, если пароль не требуется + this.http.post('/api/v1/knock-actions/execute', body).subscribe({ next: () => { this.executing = false; @@ -64,8 +71,8 @@ export class KnockPageComponent { error: (e: HttpErrorResponse) => { this.executing = false; this.stopTimer(); - this.error = (e.error?.error) || e.message; - } + this.error = e.error?.error || e.message; + }, }); } @@ -92,5 +99,3 @@ export class KnockPageComponent { } } } - -