ends article branch
This commit is contained in:
@@ -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 и типичные ошибки
|
||||||
|
@@ -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,13 +93,18 @@ 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)
|
||||||
|
@@ -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
|
||||||
)
|
)
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user