ends article branch

This commit is contained in:
2025-09-12 13:53:32 +06:00
parent f94bba14a1
commit 7909ec619e
5 changed files with 58 additions and 34 deletions

View File

@@ -1,10 +1,11 @@
# Встраиваем веб-GUI в консольную утилиту: практический гайд # Встраиваем веб-GUI в консольную утилиту: практический гайд
```metadata ```metadata
id: 10 id: 2
title: "Встраиваем веб-GUI в консольную утилиту: практический гайд"
readTime: 15-20 минут readTime: 15-20 минут
date: 2025-09-10 18:00 date: 2025-09-10 18:00
author: Direct-Dev (aka Антон Кузнецов) author: Direct-Dev (Антон)
level: Средний level: Средний
tags: #go #angular #spa #embed #static #cli #webui #devops tags: #go #angular #spa #embed #static #cli #webui #devops
version: 1.0.2 version: 1.0.2
@@ -15,7 +16,7 @@ version: 1.0.2
- [Встраиваем веб-GUI в консольную утилиту: практический гайд](#встраиваем-веб-gui-в-консольную-утилиту-практический-гайд) - [Встраиваем веб-GUI в консольную утилиту: практический гайд](#встраиваем-веб-gui-в-консольную-утилиту-практический-гайд)
- [Содержание](#содержание) - [Содержание](#содержание)
- [Введение](#введение) - [Введение](#введение)
- [Как быстро повторить (клонируем, собираем, запускаем)](ак-быстро-повторить-клонируем-собираем-запускаем) - [Клонируем, собираем, запускаем](#клонируем-собираем-запускаем)
- [Структура проекта](#структура-проекта) - [Структура проекта](#структура-проекта)
- [Идея и архитектура](#идея-и-архитектура) - [Идея и архитектура](#идея-и-архитектура)
- [Минимальный GUI](#минимальный-gui) - [Минимальный GUI](#минимальный-gui)
@@ -28,18 +29,27 @@ version: 1.0.2
## Введение ## Введение
Давайте по‑простому. Хотим к консольной утилите прикрутить небольшой веб‑интерфейс, чтобы не гонять команды вручную. Собрали один раз — и отдаем статику прямо из Goбинарника (или из рядом лежащей папки). В примере оставим только самое нужное: Допустим есть желание к консольной Go утилите прикрутить небольшой веб‑интерфейс, чтобы не гонять команды вручную в терминале или расширить круг пользователей.
Как вариант добавляем api в утилиту, делаем SPA на ангуляре, например, дальше кидаем в проект api скомпилированное spa, cобрали бинарь и отдаем статику прямо из Goбинарника (или из рядом лежащей папки).
В качестве утилиты берем go-knocker - утилиту чтобы постучаться по портам ( такая штука повышающая безопасность серверов и устройств). Проект утилиты можно найти тут: <https://github.com/Direct-Dev-Ru/port-knocker.git>
в данном упрощенном интерфейсе используем только следующие поля и inline режим работы утилиты:
на форме будут следующие поля
- Targets (строка вида `tcp:host:port;udp:host:port...`) - Targets (строка вида `tcp:host:port;udp:host:port...`)
- Delay (например `1s`) - Delay (например `1s`)
- Флаг Wait connection - Флаг Wait connection
- Кнопка Execute - Кнопка Execute
Если нужен «боевой» вариант — всегда можно нарастить поля и логику. Но начнем с минимума. Если нужен более «продвинутый» вариант — всегда можно нарастить поля и логику.
## Как быстро повторить (клонируем, собираем, запускаем) ## Клонируем, собираем, запускаем
1 Клонируем репозиторий и переключаемся на ветку для статьи: Источник репозитория: [`https://direct-dev.ru/gitea/GiteaAdmin/knock-gui`](https://direct-dev.ru/gitea/GiteaAdmin/knock-gui)
1 Клонируем репозиторий и переключаемся на ветку for-article:
```bash ```bash
# HTTPS клон # HTTPS клон
@@ -50,9 +60,7 @@ cd knock-gui
git checkout for-article 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 ```bash
cd ui cd ui
@@ -63,6 +71,7 @@ npm ci
cd .. cd ..
# или через make # или через make
cd ..
make embed-ui make embed-ui
``` ```
@@ -285,6 +294,7 @@ npx ng build --configuration production \
``` ```
Что это даёт: Что это даёт:
- В `dist/.../browser/index.html` будет `<base href="/ui/simple/">`. - В `dist/.../browser/index.html` будет `<base href="/ui/simple/">`.
- Все ссылки на бандлы/ассеты будут начинаться с `/ui/simple/`. - Все ссылки на бандлы/ассеты будут начинаться с `/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` при запросах внутри префикса, если это не файловые ресурсы. - SPAфоллбэк должен отдавать `index.html` при запросах внутри префикса, если это не файловые ресурсы.
Если у вас универсальный обработчик (как в `setupStaticRoutes`) на корне, два простых подхода: Если у вас универсальный обработчик (как в `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/`: Пример маппинга структуры в `public/`:
@@ -318,10 +330,6 @@ public/
└── ... └── ...
``` ```
Проверка:
```text
http://localhost:8888/ui/simple
```
Если при прямом заходе на вложенный роут `/ui/simple/some/child` видите 404 — значит фоллбэк не отрабатывает. Проверьте, что при отсутствии файла по этому пути сервер возвращает `public/ui/simple/index.html`. Если при прямом заходе на вложенный роут `/ui/simple/some/child` видите 404 — значит фоллбэк не отрабатывает. Проверьте, что при отсутствии файла по этому пути сервер возвращает `public/ui/simple/index.html`.
## FAQ и типичные ошибки ## FAQ и типичные ошибки

View File

@@ -58,7 +58,7 @@ func setupKnockRoutes(api *gin.RouterGroup) {
c.JSON(400, gin.H{"error": "targets is required in inline mode"}) c.JSON(400, gin.H{"error": "targets is required in inline mode"})
return return
} }
config, err := parseInlineTargetsWithWait(req.Targets, req.Delay, req.WaitConnection) config, err := parseInlineTargetsWithWait(req.Targets, req.Delay, req.WaitConnection, req.Gateway)
if err != nil { if err != nil {
c.JSON(400, gin.H{"error": fmt.Sprintf("invalid targets: %v", err)}) c.JSON(400, gin.H{"error": fmt.Sprintf("invalid targets: %v", err)})
return return
@@ -81,7 +81,7 @@ func setupKnockRoutes(api *gin.RouterGroup) {
} }
// parseInlineTargetsWithWait парсит inline строку целей в Config с поддержкой waitConnection // 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 var config internal.Config
// Парсим targets // Парсим targets
@@ -93,14 +93,19 @@ func parseInlineTargetsWithWait(targets, delay string, waitConnection bool) (int
} }
parts := strings.Split(targetStr, ":") parts := strings.Split(targetStr, ":")
if len(parts) != 3 { if !(len(parts) == 3 || len(parts) == 4) {
return config, fmt.Errorf("invalid target format: %s (expected protocol:host:port)", targetStr) return config, fmt.Errorf("invalid target format: %s (expected protocol:host:port or protocol:host:port:gateway)", targetStr)
} }
protocol := strings.TrimSpace(parts[0]) protocol := strings.TrimSpace(parts[0])
host := strings.TrimSpace(parts[1]) host := strings.TrimSpace(parts[1])
portStr := strings.TrimSpace(parts[2]) portStr := strings.TrimSpace(parts[2])
if len(parts) == 4 {
gateway = strings.TrimSpace(parts[3])
}
if protocol != "tcp" && protocol != "udp" { if protocol != "tcp" && protocol != "udp" {
return config, fmt.Errorf("unsupported protocol: %s (only tcp/udp supported)", protocol) 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}, Ports: []int{port},
Delay: targetDelay, Delay: targetDelay,
WaitConnection: waitConnection, WaitConnection: waitConnection,
Gateway: gateway,
} }
config.Targets = append(config.Targets, target) config.Targets = append(config.Targets, target)

View File

@@ -6,6 +6,7 @@ require (
github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
github.com/spf13/cobra v1.8.0 github.com/spf13/cobra v1.8.0
golang.org/x/sys v0.20.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -35,7 +36,6 @@ require (
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0 // indirect golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.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 golang.org/x/text v0.15.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect google.golang.org/protobuf v1.34.1 // indirect
) )

View File

@@ -287,7 +287,12 @@ func (pk *PortKnocker) knockTarget(target Target, verbose bool) error {
for i, port := range target.Ports { for i, port := range target.Ports {
if verbose { 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 { if err := pk.sendPacket(target.Host, port, protocol, target.WaitConnection, timeout, target.Gateway); err != nil {

View File

@@ -14,12 +14,18 @@ import { ProgressBarModule } from 'primeng/progressbar';
selector: 'app-knock-page', selector: 'app-knock-page',
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, RouterModule, ReactiveFormsModule, CommonModule,
InputTextModule, CheckboxModule, ButtonModule, CardModule, RouterModule,
DividerModule, ProgressBarModule ReactiveFormsModule,
InputTextModule,
CheckboxModule,
ButtonModule,
CardModule,
DividerModule,
ProgressBarModule,
], ],
templateUrl: './knock-page.component.html', templateUrl: './knock-page.component.html',
styleUrls: ['./knock-page.component.scss'] styleUrls: ['./knock-page.component.scss'],
}) })
export class KnockPageComponent { export class KnockPageComponent {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
@@ -36,7 +42,7 @@ export class KnockPageComponent {
form = this.fb.group({ form = this.fb.group({
targets: ['tcp:127.0.0.1:22', Validators.required], targets: ['tcp:127.0.0.1:22', Validators.required],
delay: ['1s', Validators.required], delay: ['1s', Validators.required],
waitConnection: [false] waitConnection: [false],
}); });
execute() { execute() {
@@ -48,13 +54,14 @@ export class KnockPageComponent {
const body: any = { const body: any = {
targets: v.targets, targets: v.targets,
delay: v.delay, delay: v.delay,
waitConnection: v.waitConnection waitConnection: v.waitConnection,
verbose: true,
}; };
this.executing = true; this.executing = true;
this.startTimer(); this.startTimer();
// Без обязательного Basic-Auth: заголовок не добавляется, если пароль не требуется
this.http.post('/api/v1/knock-actions/execute', body).subscribe({ this.http.post('/api/v1/knock-actions/execute', body).subscribe({
next: () => { next: () => {
this.executing = false; this.executing = false;
@@ -64,8 +71,8 @@ export class KnockPageComponent {
error: (e: HttpErrorResponse) => { error: (e: HttpErrorResponse) => {
this.executing = false; this.executing = false;
this.stopTimer(); this.stopTimer();
this.error = (e.error?.error) || e.message; this.error = e.error?.error || e.message;
} },
}); });
} }
@@ -92,5 +99,3 @@ export class KnockPageComponent {
} }
} }
} }