init
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.old
|
||||||
|
ui/node_modules
|
||||||
|
ui/dist
|
||||||
|
ui/.angular
|
||||||
|
ui/.vscode
|
||||||
|
back/cmd/public
|
||||||
|
back/knocker-serve
|
||||||
|
back/cmd/knocker-serve
|
||||||
|
back/cmd/knocker-serve.exe
|
||||||
|
back/cmd/knocker-serve.exe.sha256
|
||||||
|
back/cmd/knocker-serve.exe.sha256.txt
|
||||||
|
back/cmd/knocker-serve.exe.sha256.txt.sha256
|
||||||
|
back/cmd/knocker-serve.exe.sha256.txt.sha256.txt
|
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"djlint.showInstallError": false
|
||||||
|
}
|
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Multi-stage build: Angular -> Go -> Runtime
|
||||||
|
|
||||||
|
FROM node:22-slim AS frontend
|
||||||
|
WORKDIR /app/ui
|
||||||
|
COPY ui/package*.json ./
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
|
COPY ui .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM golang:1.23 AS backend
|
||||||
|
WORKDIR /app/back
|
||||||
|
COPY back/go.mod back/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY back .
|
||||||
|
# Copy built frontend into embed directory BEFORE go build
|
||||||
|
RUN mkdir -p /app/back/cmd/public
|
||||||
|
COPY --from=frontend /app/ui/dist/project-front/browser/ /app/back/cmd/public/
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/knocker-serve .
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/base-debian12
|
||||||
|
WORKDIR /app
|
||||||
|
ENV GO_KNOCKER_SERVE_PORT=8888
|
||||||
|
COPY --from=backend /app/knocker-serve /app/knocker-serve
|
||||||
|
EXPOSE 8888
|
||||||
|
USER nonroot:nonroot
|
||||||
|
ENTRYPOINT ["/app/knocker-serve", "serve"]
|
||||||
|
|
64
Makefile
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Root Makefile: сборка UI, встраивание и сборка Go, запуск и Docker
|
||||||
|
|
||||||
|
PASS ?= devpass
|
||||||
|
PORT ?= 8888
|
||||||
|
|
||||||
|
UI_DIR := ui
|
||||||
|
BACK_DIR := back
|
||||||
|
PUBLIC_DIR := $(BACK_DIR)/cmd/public
|
||||||
|
BINARY := $(BACK_DIR)/knocker-serve
|
||||||
|
|
||||||
|
.PHONY: help ui-install ui-build embed-ui back-deps back-build run run-bg docker-build docker-up docker-down clean
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Targets:"
|
||||||
|
@echo " ui-install - npm install в $(UI_DIR)"
|
||||||
|
@echo " ui-build - сборка Angular (prod)"
|
||||||
|
@echo " embed-ui - копировать dist в $(PUBLIC_DIR)"
|
||||||
|
@echo " back-deps - go mod tidy для бэкенда"
|
||||||
|
@echo " back-build - сборка Go бинаря $(BINARY)"
|
||||||
|
@echo " run - запуск сервера локально (PORT=$(PORT), PASS=$(PASS))"
|
||||||
|
@echo " run-bg - запуск сервера в фоне (nohup)"
|
||||||
|
@echo " docker-build - сборка Docker образа через compose"
|
||||||
|
@echo " docker-up - запуск контейнера (PORT=8888, PASS=$(PASS))"
|
||||||
|
@echo " docker-down - остановить контейнер"
|
||||||
|
@echo " clean - очистить встроенную статику и бинарь"
|
||||||
|
|
||||||
|
ui-install:
|
||||||
|
cd $(UI_DIR) && npm install --no-audit --no-fund
|
||||||
|
|
||||||
|
ui-build:
|
||||||
|
cd $(UI_DIR) && npm run build
|
||||||
|
|
||||||
|
embed-ui: ui-build
|
||||||
|
rm -rf $(PUBLIC_DIR)
|
||||||
|
mkdir -p $(PUBLIC_DIR)
|
||||||
|
cp -r $(UI_DIR)/dist/project-front/browser/* $(PUBLIC_DIR)/
|
||||||
|
rm -f $(PUBLIC_DIR)/media/Inter-*.woff2
|
||||||
|
|
||||||
|
back-deps:
|
||||||
|
cd $(BACK_DIR) && go mod tidy
|
||||||
|
|
||||||
|
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 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'
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
docker-up:
|
||||||
|
GO_KNOCKER_SERVE_PASS=$(PASS) docker compose up -d
|
||||||
|
|
||||||
|
docker-down:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf $(PUBLIC_DIR)
|
||||||
|
rm -f $(BINARY)
|
||||||
|
|
||||||
|
|
92
article.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Port-Knocker: как вам засунуть SPA в бинарь Go и остаться в живых
|
||||||
|
|
||||||
|
Если вы когда-нибудь хотели постучаться в порты так, чтобы это не было больно вашим пальцам от набивания команд в терминале — этот гайд для вас. Мы возьмём консольный `port-knocker`, прицепим к нему SPA на Angular, засунем всё в один Go-бинарь и обернём это в Docker, чтобы запускать одним махом.
|
||||||
|
|
||||||
|
## Что сделаем
|
||||||
|
|
||||||
|
- Веб-GUI на Angular для задания inline целей и YAML-конфигов, с шифрованием/дешифрованием
|
||||||
|
- REST API на Gin: `/api/v1/knock-actions/*`
|
||||||
|
- Базовая авторизация паролем через `GO_KNOCKER_SERVE_PASS`
|
||||||
|
- Встраивание фронтенда в бинарь через `go:embed`
|
||||||
|
- Docker и docker-compose для «поднял и поехал»
|
||||||
|
|
||||||
|
## Предварительно
|
||||||
|
|
||||||
|
- Go >= 1.21 (у нас 1.23)
|
||||||
|
- Node.js >= 20, npm
|
||||||
|
- Docker (если хотите контейнером)
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
``` text
|
||||||
|
embed-gui-article/
|
||||||
|
back/ # скопированный и доработанный port-knocker
|
||||||
|
cmd/serve.go # Gin сервер, API, Basic-Auth, раздача статики
|
||||||
|
cmd/public/ # сборка Angular, встраивается go:embed
|
||||||
|
ui/ # Angular 17 приложение (стартовый шаблон)
|
||||||
|
Dockerfile # multi-stage: Angular -> Go -> runtime
|
||||||
|
docker-compose.yml # запуск контейнера
|
||||||
|
article.md # вы читаете это
|
||||||
|
plan.md, уточнения.md # ТЗ и ответы
|
||||||
|
```
|
||||||
|
|
||||||
|
## API кратко
|
||||||
|
|
||||||
|
- POST `/api/v1/knock-actions/execute`
|
||||||
|
- Тело (inline): `{ targets, delay, verbose, waitConnection, gateway }`
|
||||||
|
- Тело (yaml): `{ config_yaml }`
|
||||||
|
- POST `/api/v1/knock-actions/encrypt` → `{ encrypted: "ENCRYPTED:..." }`
|
||||||
|
- POST `/api/v1/knock-actions/decrypt` → `{ yaml: "..." }`
|
||||||
|
|
||||||
|
Авторизация: Basic c пустым логином и паролем = `GO_KNOCKER_SERVE_PASS`.
|
||||||
|
|
||||||
|
## Сборка и запуск локально
|
||||||
|
|
||||||
|
- Собрать фронт и встроить (в репо уже встроено):
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
cd ui
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
cp -r dist/project-front/browser/* ../back/cmd/public/
|
||||||
|
```
|
||||||
|
|
||||||
|
- Собрать сервер и запустить:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
cd ../back
|
||||||
|
go build -o knocker-serve .
|
||||||
|
GO_KNOCKER_SERVE_PASS=devpass GO_KNOCKER_SERVE_PORT=8888 ./knocker-serve serve
|
||||||
|
```
|
||||||
|
|
||||||
|
- Открыть: `http://localhost:8888/` (указать пароль `devpass` в Basic-Auth)
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
docker compose build
|
||||||
|
GO_KNOCKER_SERVE_PASS=devpass docker compose up -d
|
||||||
|
# Открыть http://localhost:8888/
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI фичи (минимально жизненно необходимые)
|
||||||
|
|
||||||
|
- Режим inline целей или YAML
|
||||||
|
- Кнопки: Execute, Encrypt, Decrypt
|
||||||
|
- Загрузка файла (YAML/ENCRYPTED) в форму
|
||||||
|
- Скачивание YAML и результата (ENCRYPTED/YAML)
|
||||||
|
|
||||||
|
## Безопасность (по-простому)
|
||||||
|
|
||||||
|
- Пароль обязателен: `GO_KNOCKER_SERVE_PASS`
|
||||||
|
- Порт задаётся `GO_KNOCKER_SERVE_PORT` (дефолт 8888)
|
||||||
|
- Ключ шифрования конфигов берётся из пароля (внутри – через SHA-256)
|
||||||
|
- Файловые операции включены по умолчанию (отключить: `GO_KNOCKER_ENABLE_FILE_IO=0`)
|
||||||
|
|
||||||
|
## Faq
|
||||||
|
|
||||||
|
— «Почему интерфейс на английском?» — Потому что порты — международные ребята. Им так проще понимать, когда их стучат.
|
||||||
|
|
||||||
|
— «Где пасхалки?» — Только в CLI. В GUI — строго делово: стучим — открываем — уходим красиво.
|
||||||
|
|
||||||
|
Удачных постукиваний! И помните: хороший стук всегда откроет нужную дверь.
|
44
back/.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Исключить все YAML файлы содержащие "knock" в имени
|
||||||
|
*knock*.yaml
|
||||||
|
*knock*.yml
|
||||||
|
|
||||||
|
# Исключить бинарные файлы
|
||||||
|
port-knocker
|
||||||
|
port-knocker-*
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# Исключить зашифрованные конфиги
|
||||||
|
*.encrypted
|
||||||
|
|
||||||
|
# Go artifacts
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# IDE и редакторы
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Логи и временные файлы
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Персональные конфиги и ключи
|
||||||
|
key.txt
|
||||||
|
*.key
|
||||||
|
*secret*
|
95
back/Makefile
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
.PHONY: build build-linux build-windows clean test help
|
||||||
|
|
||||||
|
# Переменные
|
||||||
|
BINARY_NAME=port-knocker
|
||||||
|
VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
|
BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S')
|
||||||
|
LDFLAGS=-ldflags "-X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -s -w"
|
||||||
|
|
||||||
|
# Цвета для вывода
|
||||||
|
GREEN=\033[0;32m
|
||||||
|
NC=\033[0m # No Color
|
||||||
|
|
||||||
|
help: ## Показать справку
|
||||||
|
@echo "$(GREEN)Port Knocker - Утилита для port knocking$(NC)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Доступные команды:"
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " $(GREEN)%-15s$(NC) %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
build: ## Собрать для текущей платформы
|
||||||
|
@echo "$(GREEN)Сборка для текущей платформы...$(NC)"
|
||||||
|
go build ${LDFLAGS} -o ${BINARY_NAME} .
|
||||||
|
|
||||||
|
build-linux: ## Собрать для Linux (amd64)
|
||||||
|
@echo "$(GREEN)Сборка для Linux (amd64)...$(NC)"
|
||||||
|
GOOS=linux GOARCH=amd64 go build ${LDFLAGS} -o ${BINARY_NAME}-linux-amd64 .
|
||||||
|
|
||||||
|
build-windows: ## Собрать для Windows (amd64)
|
||||||
|
@echo "$(GREEN)Сборка для Windows (amd64)...$(NC)"
|
||||||
|
GOOS=windows GOARCH=amd64 go build ${LDFLAGS} -o ${BINARY_NAME}-windows-amd64.exe .
|
||||||
|
|
||||||
|
build-all: build-linux build-windows ## Собрать для всех платформ
|
||||||
|
|
||||||
|
test: ## Запустить тесты
|
||||||
|
@echo "$(GREEN)Запуск тестов...$(NC)"
|
||||||
|
go test -v ./...
|
||||||
|
|
||||||
|
clean: ## Очистить собранные файлы
|
||||||
|
@echo "$(GREEN)Очистка...$(NC)"
|
||||||
|
rm -f ${BINARY_NAME}*
|
||||||
|
rm -f *.exe
|
||||||
|
|
||||||
|
install: build ## Установить в систему
|
||||||
|
@echo "$(GREEN)Установка...$(NC)"
|
||||||
|
sudo cp ${BINARY_NAME} /usr/local/bin/
|
||||||
|
|
||||||
|
uninstall: ## Удалить из системы
|
||||||
|
@echo "$(GREEN)Удаление...$(NC)"
|
||||||
|
sudo rm -f /usr/local/bin/${BINARY_NAME}
|
||||||
|
|
||||||
|
deps: ## Установить зависимости
|
||||||
|
@echo "$(GREEN)Установка зависимостей...$(NC)"
|
||||||
|
go mod tidy
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
example-encrypt: ## Пример шифрования конфигурации
|
||||||
|
@echo "$(GREEN)Шифрование примера конфигурации...$(NC)"
|
||||||
|
./${BINARY_NAME} encrypt -c examples/config.yaml -o examples/config.encrypted -k examples/key.txt
|
||||||
|
|
||||||
|
example-decrypt: ## Пример расшифровки зашифрованной конфигурации
|
||||||
|
@echo "$(GREEN)Расшифровка зашифрованной конфигурации...$(NC)"
|
||||||
|
./${BINARY_NAME} decrypt -c examples/config.encrypted -o examples/config.decrypted.yaml -k examples/key.txt
|
||||||
|
|
||||||
|
example-encrypt-alt: ## Пример шифрования с опцией -i
|
||||||
|
@echo "$(GREEN)Шифрование с опцией -i...$(NC)"
|
||||||
|
./${BINARY_NAME} encrypt -i examples/config.yaml -o examples/config.encrypted -k examples/key.txt
|
||||||
|
|
||||||
|
example-run: ## Пример запуска с обычной конфигурацией
|
||||||
|
@echo "$(GREEN)Запуск с обычной конфигурацией...$(NC)"
|
||||||
|
./${BINARY_NAME} -c examples/config.yaml -v
|
||||||
|
|
||||||
|
example-inline: ## Пример запуска с инлайн целями
|
||||||
|
@echo "$(GREEN)Запуск с инлайн целями...$(NC)"
|
||||||
|
./${BINARY_NAME} -t "tcp:192.168.1.1:22;tcp:192.168.1.1:80;udp:192.168.1.1:53" -d 500ms -v
|
||||||
|
|
||||||
|
example-inline-simple: ## Простой пример с одной целью
|
||||||
|
@echo "$(GREEN)Простой пример с одной целью...$(NC)"
|
||||||
|
./${BINARY_NAME} -t "tcp:127.0.0.1:22" -v
|
||||||
|
|
||||||
|
release-tag: ## Создать git tag для release (например: make release-tag VERSION=v1.0.0)
|
||||||
|
@if [ -z "$(VERSION)" ]; then \
|
||||||
|
echo "Использование: make release-tag VERSION=v1.0.0"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@echo "$(GREEN)Создание тега $(VERSION)...$(NC)"
|
||||||
|
git tag -a $(VERSION) -m "Release $(VERSION)"
|
||||||
|
git push origin $(VERSION)
|
||||||
|
|
||||||
|
check-git: ## Проверить git статус перед коммитом
|
||||||
|
@echo "$(GREEN)Проверка git статуса...$(NC)"
|
||||||
|
git status
|
||||||
|
@echo ""
|
||||||
|
@echo "$(GREEN)Файлы в .gitignore:$(NC)"
|
||||||
|
@echo "- *knock*.yaml (конфигурации с 'knock' в имени)"
|
||||||
|
@echo "- *.encrypted (зашифрованные файлы)"
|
||||||
|
@echo "- Бинарные файлы и ключи"
|
331
back/README.md
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
# Port Knocker
|
||||||
|
|
||||||
|
Утилита для отправки port knocking пакетов на удаленные серверы с поддержкой шифрования конфигурации.
|
||||||
|
|
||||||
|
**Версия**: 1.0.10
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- ✅ Отправка TCP и UDP пакетов
|
||||||
|
- ✅ Настраиваемые последовательности портов
|
||||||
|
- ✅ Зашифрованные конфигурационные файлы
|
||||||
|
- ✅ Автоматическое определение зашифрованных файлов
|
||||||
|
- ✅ Ключи шифрования из файла или системной переменной
|
||||||
|
- ✅ Кроссплатформенная сборка (Linux, Windows, macOS)
|
||||||
|
- ✅ Совместимость со старыми версиями ОС (Ubuntu 18.04+)
|
||||||
|
- ✅ Подробный вывод для отладки
|
||||||
|
- ✅ **Расшифровка зашифрованных конфигов в открытый YAML (команда decrypt)**
|
||||||
|
- ✅ **Пасхалка для любознательных пользователей** 🎯
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
### Сборка из исходников
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Клонировать репозиторий
|
||||||
|
git clone <repository-url>
|
||||||
|
cd port-knocker
|
||||||
|
|
||||||
|
# Установить зависимости
|
||||||
|
make deps
|
||||||
|
|
||||||
|
# Собрать для текущей платформы
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Или собрать для всех платформ
|
||||||
|
make build-all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Установка в систему
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Основная команда
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# С файлом конфигурации
|
||||||
|
port-knocker -c config.yaml [-k key.txt] [-v]
|
||||||
|
|
||||||
|
# С инлайн целями
|
||||||
|
port-knocker -t "tcp:host:port;udp:host:port" [-d delay] [-v]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Параметры
|
||||||
|
|
||||||
|
- `-c, --config` - Путь к файлу конфигурации
|
||||||
|
- `-t, --targets` - Инлайн цели в формате `[proto]:[host]:[port];[proto]:[host]:[port]`
|
||||||
|
- `-d, --delay` - Задержка между пакетами (по умолчанию 1s)
|
||||||
|
- `-k, --key` - Путь к файлу ключа шифрования
|
||||||
|
- `-v, --verbose` - Подробный вывод
|
||||||
|
- `-w, --wait-connection` - Ждать установления соединения
|
||||||
|
|
||||||
|
**Примечание**: Нужно указать либо `-c` (файл), либо `-t` (инлайн цели), но не оба одновременно.
|
||||||
|
|
||||||
|
### Шифрование конфигурации
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Используя глобальную опцию --config
|
||||||
|
port-knocker encrypt -c config.yaml -o config.encrypted -k key.txt
|
||||||
|
|
||||||
|
# Или используя опцию -i
|
||||||
|
port-knocker encrypt -i config.yaml -o config.encrypted -k key.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Расшифровка зашифрованного конфига**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Используя глобальную опцию --config
|
||||||
|
port-knocker decrypt -c config.encrypted -o config.decrypted.yaml -k key.txt
|
||||||
|
|
||||||
|
# Или используя опцию -i
|
||||||
|
port-knocker decrypt -i config.encrypted -o config.decrypted.yaml -k key.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
- `-c/--config` или `-i/--input` — путь к файлу (если не указан -i, используется --config)
|
||||||
|
- `-o/--output` — путь к выходному файлу
|
||||||
|
- `-k/--key` — путь к ключу (или используйте переменную окружения GO_KNOCKER_SERVE_PASS)
|
||||||
|
|
||||||
|
**Важно**: Ключ любой длины автоматически хешируется SHA256 до 32 байт для AES-256.
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
Конфигурационный файл должен быть в формате YAML:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
targets:
|
||||||
|
- host: "192.168.1.100"
|
||||||
|
ports: [1000, 2000, 3000]
|
||||||
|
protocol: "tcp"
|
||||||
|
delay: "1s"
|
||||||
|
|
||||||
|
- host: "10.0.0.50"
|
||||||
|
ports: [5000, 6000, 7000, 8000]
|
||||||
|
protocol: "udp"
|
||||||
|
delay: "500ms"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Параметры цели
|
||||||
|
|
||||||
|
- `host` - IP-адрес или доменное имя цели
|
||||||
|
- `ports` - Массив портов для knocking
|
||||||
|
- `protocol` - Протокол: `tcp` или `udp`
|
||||||
|
- `delay` - Задержка между пакетами (например: `1s`, `500ms`, `2m`)
|
||||||
|
|
||||||
|
## Шифрование
|
||||||
|
|
||||||
|
### Создание ключа
|
||||||
|
|
||||||
|
Ключ может быть любой длины (автоматически хешируется до 32 байт):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создать ключ в файле (любая длина)
|
||||||
|
echo "my-secret-password" > key.txt
|
||||||
|
|
||||||
|
# Или установить системную переменную
|
||||||
|
export GO_KNOCKER_SERVE_PASS="my-secret-password"
|
||||||
|
|
||||||
|
# Можно использовать длинные пароли
|
||||||
|
echo "this-is-a-very-long-password-that-will-be-hashed-to-32-bytes" > key.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шифрование конфигурации 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Шифрование с ключом из файла
|
||||||
|
port-knocker encrypt -c config.yaml -o config.encrypted -k key.txt
|
||||||
|
|
||||||
|
# Шифрование с ключом из системной переменной
|
||||||
|
export GO_KNOCKER_SERVE_PASS="my-secret-password"
|
||||||
|
port-knocker encrypt -c config.yaml -o config.encrypted
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Расшифровка зашифрованной конфигурации**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Расшифровка с ключом из файла
|
||||||
|
port-knocker decrypt -c config.encrypted -o config.decrypted.yaml -k key.txt
|
||||||
|
|
||||||
|
# Расшифровка с ключом из системной переменной
|
||||||
|
export GO_KNOCKER_SERVE_PASS="my-secret-password"
|
||||||
|
port-knocker decrypt -c config.encrypted -o config.decrypted.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Использование зашифрованной конфигурации
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# С ключом из файла
|
||||||
|
port-knocker -c config.encrypted -k key.txt -v
|
||||||
|
|
||||||
|
# С ключом из системной переменной
|
||||||
|
export GO_KNOCKER_SERVE_PASS="my-secret-key-32-bytes-long!!"
|
||||||
|
port-knocker -c config.encrypted -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Примеры
|
||||||
|
|
||||||
|
### Пример 1: Быстрое использование с инлайн целями
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Простая последовательность TCP портов
|
||||||
|
port-knocker -t "tcp:192.168.1.100:1000;tcp:192.168.1.100:2000;tcp:192.168.1.100:3000" -v
|
||||||
|
|
||||||
|
# Смешанные протоколы с настройкой задержки
|
||||||
|
port-knocker -t "tcp:server.com:22;udp:server.com:53;tcp:server.com:80" -d 500ms -v
|
||||||
|
|
||||||
|
# Одиночный порт
|
||||||
|
port-knocker -t "tcp:192.168.1.1:22" -v
|
||||||
|
|
||||||
|
# С ожиданием соединения
|
||||||
|
port-knocker -t "tcp:192.168.1.100:443" -w -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пример 2: Конфигурационный файл
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создать конфигурацию
|
||||||
|
cat > config.yaml << EOF
|
||||||
|
targets:
|
||||||
|
- host: "192.168.1.100"
|
||||||
|
ports: [1000, 2000, 3000]
|
||||||
|
protocol: "tcp"
|
||||||
|
delay: "1s"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Запустить
|
||||||
|
port-knocker -c config.yaml -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пример 3: Зашифрованная конфигурация
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создать ключ
|
||||||
|
echo "my-secret-password" > key.txt
|
||||||
|
|
||||||
|
# Зашифровать конфигурацию
|
||||||
|
port-knocker encrypt -c config.yaml -o config.encrypted -k key.txt
|
||||||
|
|
||||||
|
# Использовать зашифрованную конфигурацию
|
||||||
|
port-knocker -c config.encrypted -k key.txt -v
|
||||||
|
|
||||||
|
# Расшифровать обратно для редактирования
|
||||||
|
port-knocker decrypt -c config.encrypted -o config.decrypted.yaml -k key.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пример 4: Множественные цели
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > config.yaml << EOF
|
||||||
|
targets:
|
||||||
|
- host: "server1.example.com"
|
||||||
|
ports: [22, 80, 443]
|
||||||
|
protocol: "tcp"
|
||||||
|
delay: "2s"
|
||||||
|
|
||||||
|
- host: "server2.example.com"
|
||||||
|
ports: [5000, 6000, 7000, 8000]
|
||||||
|
protocol: "udp"
|
||||||
|
delay: "500ms"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
port-knocker -c config.yaml -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Пасхалка
|
||||||
|
|
||||||
|
Попробуйте найти скрытую функцию! Запустите:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
port-knocker -t "tcp:8.8.8.8:8888" -v
|
||||||
|
```
|
||||||
|
|
||||||
|
И посмотрите, что произойдет! 🚀
|
||||||
|
|
||||||
|
## Совместимость
|
||||||
|
|
||||||
|
### Поддерживаемые системы
|
||||||
|
|
||||||
|
**Linux:**
|
||||||
|
|
||||||
|
- Ubuntu 18.04+ (GLIBC 2.27+)
|
||||||
|
- CentOS 7+ (GLIBC 2.17+)
|
||||||
|
- Debian 9+ (GLIBC 2.24+)
|
||||||
|
- RHEL 7+ (GLIBC 2.17+)
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
|
||||||
|
- Windows 7+
|
||||||
|
- Windows Server 2012+
|
||||||
|
|
||||||
|
**macOS:**
|
||||||
|
|
||||||
|
- macOS 10.14+ (Mojave)
|
||||||
|
- macOS 11+ (Big Sur)
|
||||||
|
- macOS 12+ (Monterey)
|
||||||
|
|
||||||
|
### Сборка для старых систем
|
||||||
|
|
||||||
|
Для максимальной совместимости бинарники собираются на Ubuntu 18.04 с Go 1.20.
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- Ключи шифрования должны быть достаточно длинными и случайными
|
||||||
|
- Зашифрованные файлы имеют права доступа 600
|
||||||
|
- Системная переменная `GO_KNOCKER_SERVE_PASS` должна быть защищена
|
||||||
|
- Рекомендуется использовать файлы ключей вместо системных переменных в продакшене
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
### Для Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build-linux
|
||||||
|
```
|
||||||
|
|
||||||
|
### Для Windows
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build-windows
|
||||||
|
```
|
||||||
|
|
||||||
|
### Для всех платформ
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build-all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
### Запуск тестов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Очистка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make clean
|
||||||
|
```
|
||||||
|
|
||||||
|
### Справка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make help
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 Дополнительная документация
|
||||||
|
|
||||||
|
Для более подробной информации о создании релизов и других аспектах проекта:
|
||||||
|
|
||||||
|
- **[Документация](docs/)** - Подробные инструкции и руководства
|
||||||
|
- **[Ручное создание релизов](docs/manual-release.md)** - Пошаговая инструкция
|
||||||
|
- **[Чек-лист релизов](docs/release-checklist.md)** - Быстрый чек-лист
|
||||||
|
- **[Скрипт быстрого релиза](docs/scripts/quick-release.sh)** - Автоматизация
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT License
|
338
back/bash_completion.sh
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
# bash completion V2 for port-knocker -*- shell-script -*-
|
||||||
|
|
||||||
|
__port-knocker_debug()
|
||||||
|
{
|
||||||
|
if [[ -n ${BASH_COMP_DEBUG_FILE-} ]]; then
|
||||||
|
echo "$*" >> "${BASH_COMP_DEBUG_FILE}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Macs have bash3 for which the bash-completion package doesn't include
|
||||||
|
# _init_completion. This is a minimal version of that function.
|
||||||
|
__port-knocker_init_completion()
|
||||||
|
{
|
||||||
|
COMPREPLY=()
|
||||||
|
_get_comp_words_by_ref "$@" cur prev words cword
|
||||||
|
}
|
||||||
|
|
||||||
|
# This function calls the port-knocker program to obtain the completion
|
||||||
|
# results and the directive. It fills the 'out' and 'directive' vars.
|
||||||
|
__port-knocker_get_completion_results() {
|
||||||
|
local requestComp lastParam lastChar args
|
||||||
|
|
||||||
|
# Prepare the command to request completions for the program.
|
||||||
|
# Calling ${words[0]} instead of directly port-knocker allows handling aliases
|
||||||
|
args=("${words[@]:1}")
|
||||||
|
requestComp="${words[0]} __complete ${args[*]}"
|
||||||
|
|
||||||
|
lastParam=${words[$((${#words[@]}-1))]}
|
||||||
|
lastChar=${lastParam:$((${#lastParam}-1)):1}
|
||||||
|
__port-knocker_debug "lastParam ${lastParam}, lastChar ${lastChar}"
|
||||||
|
|
||||||
|
if [[ -z ${cur} && ${lastChar} != = ]]; then
|
||||||
|
# If the last parameter is complete (there is a space following it)
|
||||||
|
# We add an extra empty parameter so we can indicate this to the go method.
|
||||||
|
__port-knocker_debug "Adding extra empty parameter"
|
||||||
|
requestComp="${requestComp} ''"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# When completing a flag with an = (e.g., port-knocker -n=<TAB>)
|
||||||
|
# bash focuses on the part after the =, so we need to remove
|
||||||
|
# the flag part from $cur
|
||||||
|
if [[ ${cur} == -*=* ]]; then
|
||||||
|
cur="${cur#*=}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
__port-knocker_debug "Calling ${requestComp}"
|
||||||
|
# Use eval to handle any environment variables and such
|
||||||
|
out=$(eval "${requestComp}" 2>/dev/null)
|
||||||
|
|
||||||
|
# Extract the directive integer at the very end of the output following a colon (:)
|
||||||
|
directive=${out##*:}
|
||||||
|
# Remove the directive
|
||||||
|
out=${out%:*}
|
||||||
|
if [[ ${directive} == "${out}" ]]; then
|
||||||
|
# There is not directive specified
|
||||||
|
directive=0
|
||||||
|
fi
|
||||||
|
__port-knocker_debug "The completion directive is: ${directive}"
|
||||||
|
__port-knocker_debug "The completions are: ${out}"
|
||||||
|
}
|
||||||
|
|
||||||
|
__port-knocker_process_completion_results() {
|
||||||
|
local shellCompDirectiveError=1
|
||||||
|
local shellCompDirectiveNoSpace=2
|
||||||
|
local shellCompDirectiveNoFileComp=4
|
||||||
|
local shellCompDirectiveFilterFileExt=8
|
||||||
|
local shellCompDirectiveFilterDirs=16
|
||||||
|
local shellCompDirectiveKeepOrder=32
|
||||||
|
|
||||||
|
if (((directive & shellCompDirectiveError) != 0)); then
|
||||||
|
# Error code. No completion.
|
||||||
|
__port-knocker_debug "Received error from custom completion go code"
|
||||||
|
return
|
||||||
|
else
|
||||||
|
if (((directive & shellCompDirectiveNoSpace) != 0)); then
|
||||||
|
if [[ $(type -t compopt) == builtin ]]; then
|
||||||
|
__port-knocker_debug "Activating no space"
|
||||||
|
compopt -o nospace
|
||||||
|
else
|
||||||
|
__port-knocker_debug "No space directive not supported in this version of bash"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if (((directive & shellCompDirectiveKeepOrder) != 0)); then
|
||||||
|
if [[ $(type -t compopt) == builtin ]]; then
|
||||||
|
# no sort isn't supported for bash less than < 4.4
|
||||||
|
if [[ ${BASH_VERSINFO[0]} -lt 4 || ( ${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -lt 4 ) ]]; then
|
||||||
|
__port-knocker_debug "No sort directive not supported in this version of bash"
|
||||||
|
else
|
||||||
|
__port-knocker_debug "Activating keep order"
|
||||||
|
compopt -o nosort
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
__port-knocker_debug "No sort directive not supported in this version of bash"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if (((directive & shellCompDirectiveNoFileComp) != 0)); then
|
||||||
|
if [[ $(type -t compopt) == builtin ]]; then
|
||||||
|
__port-knocker_debug "Activating no file completion"
|
||||||
|
compopt +o default
|
||||||
|
else
|
||||||
|
__port-knocker_debug "No file completion directive not supported in this version of bash"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Separate activeHelp from normal completions
|
||||||
|
local completions=()
|
||||||
|
local activeHelp=()
|
||||||
|
__port-knocker_extract_activeHelp
|
||||||
|
|
||||||
|
if (((directive & shellCompDirectiveFilterFileExt) != 0)); then
|
||||||
|
# File extension filtering
|
||||||
|
local fullFilter filter filteringCmd
|
||||||
|
|
||||||
|
# Do not use quotes around the $completions variable or else newline
|
||||||
|
# characters will be kept.
|
||||||
|
for filter in ${completions[*]}; do
|
||||||
|
fullFilter+="$filter|"
|
||||||
|
done
|
||||||
|
|
||||||
|
filteringCmd="_filedir $fullFilter"
|
||||||
|
__port-knocker_debug "File filtering command: $filteringCmd"
|
||||||
|
$filteringCmd
|
||||||
|
elif (((directive & shellCompDirectiveFilterDirs) != 0)); then
|
||||||
|
# File completion for directories only
|
||||||
|
|
||||||
|
local subdir
|
||||||
|
subdir=${completions[0]}
|
||||||
|
if [[ -n $subdir ]]; then
|
||||||
|
__port-knocker_debug "Listing directories in $subdir"
|
||||||
|
pushd "$subdir" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return
|
||||||
|
else
|
||||||
|
__port-knocker_debug "Listing directories in ."
|
||||||
|
_filedir -d
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
__port-knocker_handle_completion_types
|
||||||
|
fi
|
||||||
|
|
||||||
|
__port-knocker_handle_special_char "$cur" :
|
||||||
|
__port-knocker_handle_special_char "$cur" =
|
||||||
|
|
||||||
|
# Print the activeHelp statements before we finish
|
||||||
|
if ((${#activeHelp[*]} != 0)); then
|
||||||
|
printf "\n";
|
||||||
|
printf "%s\n" "${activeHelp[@]}"
|
||||||
|
printf "\n"
|
||||||
|
|
||||||
|
# The prompt format is only available from bash 4.4.
|
||||||
|
# We test if it is available before using it.
|
||||||
|
if (x=${PS1@P}) 2> /dev/null; then
|
||||||
|
printf "%s" "${PS1@P}${COMP_LINE[@]}"
|
||||||
|
else
|
||||||
|
# Can't print the prompt. Just print the
|
||||||
|
# text the user had typed, it is workable enough.
|
||||||
|
printf "%s" "${COMP_LINE[@]}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Separate activeHelp lines from real completions.
|
||||||
|
# Fills the $activeHelp and $completions arrays.
|
||||||
|
__port-knocker_extract_activeHelp() {
|
||||||
|
local activeHelpMarker="_activeHelp_ "
|
||||||
|
local endIndex=${#activeHelpMarker}
|
||||||
|
|
||||||
|
while IFS='' read -r comp; do
|
||||||
|
if [[ ${comp:0:endIndex} == $activeHelpMarker ]]; then
|
||||||
|
comp=${comp:endIndex}
|
||||||
|
__port-knocker_debug "ActiveHelp found: $comp"
|
||||||
|
if [[ -n $comp ]]; then
|
||||||
|
activeHelp+=("$comp")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Not an activeHelp line but a normal completion
|
||||||
|
completions+=("$comp")
|
||||||
|
fi
|
||||||
|
done <<<"${out}"
|
||||||
|
}
|
||||||
|
|
||||||
|
__port-knocker_handle_completion_types() {
|
||||||
|
__port-knocker_debug "__port-knocker_handle_completion_types: COMP_TYPE is $COMP_TYPE"
|
||||||
|
|
||||||
|
case $COMP_TYPE in
|
||||||
|
37|42)
|
||||||
|
# Type: menu-complete/menu-complete-backward and insert-completions
|
||||||
|
# If the user requested inserting one completion at a time, or all
|
||||||
|
# completions at once on the command-line we must remove the descriptions.
|
||||||
|
# https://github.com/spf13/cobra/issues/1508
|
||||||
|
local tab=$'\t' comp
|
||||||
|
while IFS='' read -r comp; do
|
||||||
|
[[ -z $comp ]] && continue
|
||||||
|
# Strip any description
|
||||||
|
comp=${comp%%$tab*}
|
||||||
|
# Only consider the completions that match
|
||||||
|
if [[ $comp == "$cur"* ]]; then
|
||||||
|
COMPREPLY+=("$comp")
|
||||||
|
fi
|
||||||
|
done < <(printf "%s\n" "${completions[@]}")
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
# Type: complete (normal completion)
|
||||||
|
__port-knocker_handle_standard_completion_case
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
__port-knocker_handle_standard_completion_case() {
|
||||||
|
local tab=$'\t' comp
|
||||||
|
|
||||||
|
# Short circuit to optimize if we don't have descriptions
|
||||||
|
if [[ "${completions[*]}" != *$tab* ]]; then
|
||||||
|
IFS=$'\n' read -ra COMPREPLY -d '' < <(compgen -W "${completions[*]}" -- "$cur")
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local longest=0
|
||||||
|
local compline
|
||||||
|
# Look for the longest completion so that we can format things nicely
|
||||||
|
while IFS='' read -r compline; do
|
||||||
|
[[ -z $compline ]] && continue
|
||||||
|
# Strip any description before checking the length
|
||||||
|
comp=${compline%%$tab*}
|
||||||
|
# Only consider the completions that match
|
||||||
|
[[ $comp == "$cur"* ]] || continue
|
||||||
|
COMPREPLY+=("$compline")
|
||||||
|
if ((${#comp}>longest)); then
|
||||||
|
longest=${#comp}
|
||||||
|
fi
|
||||||
|
done < <(printf "%s\n" "${completions[@]}")
|
||||||
|
|
||||||
|
# If there is a single completion left, remove the description text
|
||||||
|
if ((${#COMPREPLY[*]} == 1)); then
|
||||||
|
__port-knocker_debug "COMPREPLY[0]: ${COMPREPLY[0]}"
|
||||||
|
comp="${COMPREPLY[0]%%$tab*}"
|
||||||
|
__port-knocker_debug "Removed description from single completion, which is now: ${comp}"
|
||||||
|
COMPREPLY[0]=$comp
|
||||||
|
else # Format the descriptions
|
||||||
|
__port-knocker_format_comp_descriptions $longest
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
__port-knocker_handle_special_char()
|
||||||
|
{
|
||||||
|
local comp="$1"
|
||||||
|
local char=$2
|
||||||
|
if [[ "$comp" == *${char}* && "$COMP_WORDBREAKS" == *${char}* ]]; then
|
||||||
|
local word=${comp%"${comp##*${char}}"}
|
||||||
|
local idx=${#COMPREPLY[*]}
|
||||||
|
while ((--idx >= 0)); do
|
||||||
|
COMPREPLY[idx]=${COMPREPLY[idx]#"$word"}
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
__port-knocker_format_comp_descriptions()
|
||||||
|
{
|
||||||
|
local tab=$'\t'
|
||||||
|
local comp desc maxdesclength
|
||||||
|
local longest=$1
|
||||||
|
|
||||||
|
local i ci
|
||||||
|
for ci in ${!COMPREPLY[*]}; do
|
||||||
|
comp=${COMPREPLY[ci]}
|
||||||
|
# Properly format the description string which follows a tab character if there is one
|
||||||
|
if [[ "$comp" == *$tab* ]]; then
|
||||||
|
__port-knocker_debug "Original comp: $comp"
|
||||||
|
desc=${comp#*$tab}
|
||||||
|
comp=${comp%%$tab*}
|
||||||
|
|
||||||
|
# $COLUMNS stores the current shell width.
|
||||||
|
# Remove an extra 4 because we add 2 spaces and 2 parentheses.
|
||||||
|
maxdesclength=$(( COLUMNS - longest - 4 ))
|
||||||
|
|
||||||
|
# Make sure we can fit a description of at least 8 characters
|
||||||
|
# if we are to align the descriptions.
|
||||||
|
if ((maxdesclength > 8)); then
|
||||||
|
# Add the proper number of spaces to align the descriptions
|
||||||
|
for ((i = ${#comp} ; i < longest ; i++)); do
|
||||||
|
comp+=" "
|
||||||
|
done
|
||||||
|
else
|
||||||
|
# Don't pad the descriptions so we can fit more text after the completion
|
||||||
|
maxdesclength=$(( COLUMNS - ${#comp} - 4 ))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If there is enough space for any description text,
|
||||||
|
# truncate the descriptions that are too long for the shell width
|
||||||
|
if ((maxdesclength > 0)); then
|
||||||
|
if ((${#desc} > maxdesclength)); then
|
||||||
|
desc=${desc:0:$(( maxdesclength - 1 ))}
|
||||||
|
desc+="…"
|
||||||
|
fi
|
||||||
|
comp+=" ($desc)"
|
||||||
|
fi
|
||||||
|
COMPREPLY[ci]=$comp
|
||||||
|
__port-knocker_debug "Final comp: $comp"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
__start_port-knocker()
|
||||||
|
{
|
||||||
|
local cur prev words cword split
|
||||||
|
|
||||||
|
COMPREPLY=()
|
||||||
|
|
||||||
|
# Call _init_completion from the bash-completion package
|
||||||
|
# to prepare the arguments properly
|
||||||
|
if declare -F _init_completion >/dev/null 2>&1; then
|
||||||
|
_init_completion -n =: || return
|
||||||
|
else
|
||||||
|
__port-knocker_init_completion -n =: || return
|
||||||
|
fi
|
||||||
|
|
||||||
|
__port-knocker_debug
|
||||||
|
__port-knocker_debug "========= starting completion logic =========="
|
||||||
|
__port-knocker_debug "cur is ${cur}, words[*] is ${words[*]}, #words[@] is ${#words[@]}, cword is $cword"
|
||||||
|
|
||||||
|
# The user could have moved the cursor backwards on the command-line.
|
||||||
|
# We need to trigger completion from the $cword location, so we need
|
||||||
|
# to truncate the command-line ($words) up to the $cword location.
|
||||||
|
words=("${words[@]:0:$cword+1}")
|
||||||
|
__port-knocker_debug "Truncated words[*]: ${words[*]},"
|
||||||
|
|
||||||
|
local out directive
|
||||||
|
__port-knocker_get_completion_results
|
||||||
|
__port-knocker_process_completion_results
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ $(type -t compopt) = "builtin" ]]; then
|
||||||
|
complete -o default -F __start_port-knocker port-knocker
|
||||||
|
else
|
||||||
|
complete -o default -o nospace -F __start_port-knocker port-knocker
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ex: ts=4 sw=4 et filetype=sh
|
258
back/cmd/crypto_routes.go
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupCryptoRoutes настраивает роуты для шифрования/дешифрования
|
||||||
|
func setupCryptoRoutes(api *gin.RouterGroup, passHash [32]byte) {
|
||||||
|
// Encrypt: вход YAML (и опционально path). Если path указан: можно не передавать yaml,
|
||||||
|
// тогда читаем из файла и пишем шифровку в этот же путь
|
||||||
|
api.POST("/encrypt", func(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Yaml string `json:"yaml"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
if err := c.BindJSON(&req); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": fmt.Sprintf("bad json: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Yaml) == "" {
|
||||||
|
c.JSON(400, gin.H{"error": "yaml is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлекаем path из YAML для автоматической записи файла
|
||||||
|
var yamlPath string
|
||||||
|
if yamlStr := strings.TrimSpace(req.Yaml); yamlStr != "" {
|
||||||
|
var config map[string]interface{}
|
||||||
|
if err := yaml.Unmarshal([]byte(yamlStr), &config); err == nil {
|
||||||
|
if pathVal, ok := config["path"].(string); ok {
|
||||||
|
yamlPath = strings.TrimSpace(pathVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем path из запроса или из YAML
|
||||||
|
targetPath := strings.TrimSpace(req.Path)
|
||||||
|
if targetPath == "" && yamlPath != "" {
|
||||||
|
targetPath = yamlPath
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted, err := encryptBytes([]byte(req.Yaml), passHash[:])
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := "ENCRYPTED:" + encrypted
|
||||||
|
|
||||||
|
if targetPath != "" {
|
||||||
|
if !isFileIOEnabled() {
|
||||||
|
c.JSON(403, gin.H{"error": "file I/O is disabled; set GO_KNOCKER_ENABLE_FILE_IO=1 or remove GO_KNOCKER_ENABLE_FILE_IO=0"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(targetPath, []byte(out), 0600); err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": fmt.Sprintf("write failed: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"status": "ok", "encrypted": out, "path": targetPath})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"encrypted": out})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Decrypt: вход ENCRYPTED:... (и опционально path). Если path указан: можно не передавать encrypted,
|
||||||
|
// тогда читаем из файла и пишем расшифровку в этот же путь
|
||||||
|
api.POST("/decrypt", func(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Encrypted string `json:"encrypted"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
if err := c.BindJSON(&req); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": fmt.Sprintf("bad json: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var enc string
|
||||||
|
if strings.TrimSpace(req.Path) != "" && strings.TrimSpace(req.Encrypted) == "" {
|
||||||
|
if !isFileIOEnabled() {
|
||||||
|
c.JSON(403, gin.H{"error": "file I/O is disabled; set GO_KNOCKER_ENABLE_FILE_IO=1 or remove GO_KNOCKER_ENABLE_FILE_IO=0"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": fmt.Sprintf("read failed: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enc = string(data)
|
||||||
|
} else {
|
||||||
|
enc = req.Encrypted
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(enc, "ENCRYPTED:") {
|
||||||
|
c.JSON(400, gin.H{"error": "input must start with ENCRYPTED:"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plain, err := decryptString(enc[10:], passHash[:])
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлекаем path из расшифрованного YAML
|
||||||
|
var yamlPath string
|
||||||
|
if plainStr := string(plain); strings.TrimSpace(plainStr) != "" {
|
||||||
|
var config map[string]interface{}
|
||||||
|
if err := yaml.Unmarshal(plain, &config); err == nil {
|
||||||
|
if pathVal, ok := config["path"].(string); ok {
|
||||||
|
yamlPath = strings.TrimSpace(pathVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем path из запроса или из YAML
|
||||||
|
targetPath := strings.TrimSpace(req.Path)
|
||||||
|
if targetPath == "" && yamlPath != "" {
|
||||||
|
targetPath = yamlPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetPath != "" {
|
||||||
|
if !isFileIOEnabled() {
|
||||||
|
c.JSON(403, gin.H{"error": "file I/O is disabled; set GO_KNOCKER_ENABLE_FILE_IO=1 or remove GO_KNOCKER_ENABLE_FILE_IO=0"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(targetPath, plain, 0600); err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": fmt.Sprintf("write failed: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"status": "ok", "yaml": string(plain), "path": targetPath})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"yaml": string(plain)})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Encrypt file on server filesystem
|
||||||
|
api.POST("/encrypt-file", func(c *gin.Context) {
|
||||||
|
if !isFileIOEnabled() {
|
||||||
|
c.JSON(403, gin.H{"error": "file I/O is disabled; set GO_KNOCKER_ENABLE_FILE_IO=1 or remove GO_KNOCKER_ENABLE_FILE_IO=0"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
if err := c.BindJSON(&req); err != nil || strings.TrimSpace(req.Path) == "" {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid path"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": fmt.Sprintf("read failed: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encrypted, err := encryptBytes(data, passHash[:])
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := "ENCRYPTED:" + encrypted
|
||||||
|
if err := os.WriteFile(req.Path, []byte(out), 0600); err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": fmt.Sprintf("write failed: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"status": "ok", "encrypted": out})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Decrypt file on server filesystem
|
||||||
|
api.POST("/decrypt-file", func(c *gin.Context) {
|
||||||
|
if !isFileIOEnabled() {
|
||||||
|
c.JSON(403, gin.H{"error": "file I/O is disabled; set GO_KNOCKER_ENABLE_FILE_IO=1 or remove GO_KNOCKER_ENABLE_FILE_IO=0"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
if err := c.BindJSON(&req); err != nil || strings.TrimSpace(req.Path) == "" {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid path"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": fmt.Sprintf("read failed: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enc := string(data)
|
||||||
|
if !strings.HasPrefix(enc, "ENCRYPTED:") {
|
||||||
|
c.JSON(400, gin.H{"error": "file must start with ENCRYPTED:"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plain, err := decryptString(enc[10:], passHash[:])
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(req.Path, plain, 0600); err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": fmt.Sprintf("write failed: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"status": "ok", "yaml": string(plain)})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptBytes шифрует байты с помощью AES-GCM
|
||||||
|
func encryptBytes(data []byte, key []byte) (string, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, data, nil)
|
||||||
|
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptString дешифрует строку с помощью AES-GCM
|
||||||
|
func decryptString(encrypted string, key []byte) ([]byte, error) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(data) < nonceSize {
|
||||||
|
return nil, fmt.Errorf("ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
130
back/cmd/decrypt.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/sha256"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var decryptCmd = &cobra.Command{
|
||||||
|
Use: "decrypt",
|
||||||
|
Short: "Расшифровать зашифрованный конфиг в открытый YAML",
|
||||||
|
Long: `Расшифровывает зашифрованный конфигурационный файл (ENCRYPTED:...) в обычный YAML-файл`,
|
||||||
|
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
// Для команды decrypt config не обязателен если есть -i
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
RunE: runDecrypt,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
decryptInputFile string
|
||||||
|
decryptOutputFile string
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(decryptCmd)
|
||||||
|
decryptCmd.Flags().StringVarP(&decryptInputFile, "input", "i", "", "Входной зашифрованный файл (если не указан, используется --config)")
|
||||||
|
decryptCmd.Flags().StringVarP(&decryptOutputFile, "output", "o", "", "Выходной YAML-файл")
|
||||||
|
decryptCmd.MarkFlagRequired("output")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDecrypt(cmd *cobra.Command, args []string) error {
|
||||||
|
// Определяем входной файл: либо из -i, либо из глобального --config
|
||||||
|
input := decryptInputFile
|
||||||
|
if input == "" {
|
||||||
|
input = configFile
|
||||||
|
if input == "" {
|
||||||
|
return fmt.Errorf("необходимо указать входной файл через -i или --config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(input)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось прочитать входной файл %s: %w", input, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(string(data), "ENCRYPTED:") {
|
||||||
|
return fmt.Errorf("файл %s не является зашифрованным (нет префикса ENCRYPTED:)", input)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := getDecryptionKeyHashed(keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось получить ключ шифрования: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := decryptData(data[10:], key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось расшифровать данные: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(decryptOutputFile, decrypted, 0600); err != nil {
|
||||||
|
return fmt.Errorf("не удалось записать YAML-файл: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Файл успешно расшифрован: %s → %s\n", input, decryptOutputFile)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDecryptionKeyHashed получает ключ шифрования и хеширует его до 32 байт (аналогично encrypt)
|
||||||
|
func getDecryptionKeyHashed(keyFile string) ([]byte, error) {
|
||||||
|
var rawKey []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if keyFile != "" {
|
||||||
|
// Читаем ключ из файла
|
||||||
|
rawKey, err = os.ReadFile(keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось прочитать файл ключа: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Пытаемся получить ключ из системной переменной
|
||||||
|
key := os.Getenv("GO_KNOCKER_SERVE_PASS")
|
||||||
|
if key == "" {
|
||||||
|
return nil, fmt.Errorf("ключ шифрования не найден ни в файле, ни в переменной GO_KNOCKER_SERVE_PASS")
|
||||||
|
}
|
||||||
|
rawKey = []byte(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Хешируем ключ SHA256 чтобы получить всегда 32 байта
|
||||||
|
hash := sha256.Sum256(rawKey)
|
||||||
|
return hash[:], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptData расшифровывает данные с помощью AES-GCM (аналогично internal)
|
||||||
|
func decryptData(encryptedData []byte, key []byte) ([]byte, error) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(string(encryptedData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось декодировать base64: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось создать AES cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось создать GCM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(data) < nonceSize {
|
||||||
|
return nil, fmt.Errorf("данные слишком короткие")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось расшифровать: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
127
back/cmd/encrypt.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var encryptCmd = &cobra.Command{
|
||||||
|
Use: "encrypt",
|
||||||
|
Short: "Зашифровать конфигурационный файл",
|
||||||
|
Long: `Зашифровывает YAML конфигурационный файл с помощью AES-GCM шифрования`,
|
||||||
|
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
// Для команды encrypt config не обязателен если есть -i
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
RunE: runEncrypt,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(encryptCmd)
|
||||||
|
encryptCmd.Flags().StringVarP(&inputFile, "input", "i", "", "Входной файл для шифрования (если не указан, используется --config)")
|
||||||
|
encryptCmd.Flags().StringVarP(&outputFile, "output", "o", "", "Выходной зашифрованный файл")
|
||||||
|
encryptCmd.MarkFlagRequired("output")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
inputFile string
|
||||||
|
outputFile string
|
||||||
|
)
|
||||||
|
|
||||||
|
func runEncrypt(cmd *cobra.Command, args []string) error {
|
||||||
|
// Определяем входной файл: либо из -i, либо из глобального --config
|
||||||
|
input := inputFile
|
||||||
|
if input == "" {
|
||||||
|
input = configFile
|
||||||
|
if input == "" {
|
||||||
|
return fmt.Errorf("необходимо указать входной файл через -i или --config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем входной файл
|
||||||
|
data, err := os.ReadFile(input)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось прочитать входной файл %s: %w", input, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем ключ шифрования
|
||||||
|
key, err := getEncryptionKeyHashed(keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось получить ключ шифрования: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шифруем данные
|
||||||
|
encryptedData, err := encrypt(data, key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось зашифровать данные: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Записываем зашифрованный файл с префиксом "ENCRYPTED:"
|
||||||
|
output := "ENCRYPTED:" + encryptedData
|
||||||
|
if err := os.WriteFile(outputFile, []byte(output), 0600); err != nil {
|
||||||
|
return fmt.Errorf("не удалось записать зашифрованный файл: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Файл успешно зашифрован: %s → %s\n", input, outputFile)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEncryptionKeyHashed получает ключ шифрования и хеширует его до 32 байт
|
||||||
|
func getEncryptionKeyHashed(keyFile string) ([]byte, error) {
|
||||||
|
var rawKey []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if keyFile != "" {
|
||||||
|
// Читаем ключ из файла
|
||||||
|
rawKey, err = os.ReadFile(keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось прочитать файл ключа: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Пытаемся получить ключ из системной переменной
|
||||||
|
key := os.Getenv("GO_KNOCKER_SERVE_PASS")
|
||||||
|
if key == "" {
|
||||||
|
return nil, fmt.Errorf("ключ шифрования не найден ни в файле, ни в переменной GO_KNOCKER_SERVE_PASS")
|
||||||
|
}
|
||||||
|
rawKey = []byte(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Хешируем ключ SHA256 чтобы получить всегда 32 байта
|
||||||
|
hash := sha256.Sum256(rawKey)
|
||||||
|
return hash[:], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encrypt шифрует данные с помощью AES-GCM
|
||||||
|
func encrypt(plaintext []byte, key []byte) (string, error) {
|
||||||
|
// Создаем AES cipher
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("не удалось создать AES cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем GCM
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("не удалось создать GCM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем nonce
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", fmt.Errorf("не удалось создать nonce: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шифруем
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||||
|
|
||||||
|
// Кодируем в base64
|
||||||
|
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||||
|
}
|
141
back/cmd/knock_routes.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"port-knocker/internal"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupKnockRoutes настраивает роуты для выполнения port knocking
|
||||||
|
func setupKnockRoutes(api *gin.RouterGroup) {
|
||||||
|
// Execute: вход inline или YAML конфиг
|
||||||
|
api.POST("/execute", func(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Targets string `json:"targets"`
|
||||||
|
Delay string `json:"delay"`
|
||||||
|
Verbose bool `json:"verbose"`
|
||||||
|
WaitConnection bool `json:"waitConnection"`
|
||||||
|
Gateway string `json:"gateway"`
|
||||||
|
ConfigYaml string `json:"config_yaml"`
|
||||||
|
}
|
||||||
|
if err := c.BindJSON(&req); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": fmt.Sprintf("bad json: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
knocker := internal.NewPortKnocker()
|
||||||
|
|
||||||
|
// Определяем режим: inline или YAML
|
||||||
|
if strings.TrimSpace(req.ConfigYaml) != "" {
|
||||||
|
// YAML режим - загружаем конфигурацию из строки
|
||||||
|
config, err := internal.LoadConfigFromString(req.ConfigYaml)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": fmt.Sprintf("invalid yaml: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Применяем дополнительные параметры из запроса
|
||||||
|
if req.Gateway != "" {
|
||||||
|
for i := range config.Targets {
|
||||||
|
config.Targets[i].Gateway = req.Gateway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := knocker.ExecuteWithConfig(config, req.Verbose, req.WaitConnection); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
// Inline режим
|
||||||
|
if strings.TrimSpace(req.Targets) == "" {
|
||||||
|
c.JSON(400, gin.H{"error": "targets is required in inline mode"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config, err := parseInlineTargetsWithWait(req.Targets, req.Delay, req.WaitConnection)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": fmt.Sprintf("invalid targets: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Применяем gateway к каждой цели
|
||||||
|
if req.Gateway != "" {
|
||||||
|
for i := range config.Targets {
|
||||||
|
config.Targets[i].Gateway = req.Gateway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := knocker.ExecuteWithConfig(&config, req.Verbose, req.WaitConnection); err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseInlineTargetsWithWait парсит inline строку целей в Config с поддержкой waitConnection
|
||||||
|
func parseInlineTargetsWithWait(targets, delay string, waitConnection bool) (internal.Config, error) {
|
||||||
|
var config internal.Config
|
||||||
|
|
||||||
|
// Парсим targets
|
||||||
|
targetStrings := strings.Split(targets, ";")
|
||||||
|
for _, targetStr := range targetStrings {
|
||||||
|
targetStr = strings.TrimSpace(targetStr)
|
||||||
|
if targetStr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(targetStr, ":")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return config, fmt.Errorf("invalid target format: %s (expected protocol:host:port)", targetStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol := strings.TrimSpace(parts[0])
|
||||||
|
host := strings.TrimSpace(parts[1])
|
||||||
|
portStr := strings.TrimSpace(parts[2])
|
||||||
|
|
||||||
|
if protocol != "tcp" && protocol != "udp" {
|
||||||
|
return config, fmt.Errorf("unsupported protocol: %s (only tcp/udp supported)", protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := strconv.Atoi(portStr)
|
||||||
|
if err != nil {
|
||||||
|
return config, fmt.Errorf("invalid port: %s", portStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим delay
|
||||||
|
var targetDelay internal.Duration
|
||||||
|
if delay != "" {
|
||||||
|
duration, err := time.ParseDuration(delay)
|
||||||
|
if err != nil {
|
||||||
|
return config, fmt.Errorf("invalid delay: %s", delay)
|
||||||
|
}
|
||||||
|
targetDelay = internal.Duration(duration)
|
||||||
|
} else {
|
||||||
|
targetDelay = internal.Duration(time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
target := internal.Target{
|
||||||
|
Protocol: protocol,
|
||||||
|
Host: host,
|
||||||
|
Ports: []int{port},
|
||||||
|
Delay: targetDelay,
|
||||||
|
WaitConnection: waitConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Targets = append(config.Targets, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.Targets) == 0 {
|
||||||
|
return config, fmt.Errorf("no valid targets found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
57
back/cmd/middleware.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// isFileIOEnabled проверяет, разрешены ли файловые операции
|
||||||
|
// По умолчанию включено, отключается только при GO_KNOCKER_ENABLE_FILE_IO=0
|
||||||
|
func isFileIOEnabled() bool {
|
||||||
|
env := os.Getenv("GO_KNOCKER_ENABLE_FILE_IO")
|
||||||
|
return env != "0" && env != "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
// authMiddleware - базовая авторизация: пользователь "knocker" + пароль
|
||||||
|
func authMiddleware(pass string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
user, providedPass, ok := c.Request.BasicAuth()
|
||||||
|
// Проверяем пользователя "knocker" и пароль
|
||||||
|
if !ok || (providedPass != pass || user != "knocker") {
|
||||||
|
c.Header("WWW-Authenticate", "Basic realm=Restricted")
|
||||||
|
c.AbortWithStatus(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// staticAuthMiddleware - защищает HTML страницы, но пропускает статические ресурсы
|
||||||
|
func staticAuthMiddleware(pass string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
|
||||||
|
// Пропускаем статические ресурсы без авторизации
|
||||||
|
if strings.HasSuffix(path, ".js") ||
|
||||||
|
strings.HasSuffix(path, ".css") ||
|
||||||
|
strings.HasSuffix(path, ".ico") ||
|
||||||
|
strings.HasSuffix(path, ".png") ||
|
||||||
|
strings.HasSuffix(path, ".jpg") ||
|
||||||
|
strings.HasSuffix(path, ".svg") ||
|
||||||
|
strings.HasSuffix(path, ".woff") ||
|
||||||
|
strings.HasSuffix(path, ".woff2") ||
|
||||||
|
strings.HasSuffix(path, ".ttf") ||
|
||||||
|
strings.HasSuffix(path, ".eot") ||
|
||||||
|
strings.HasSuffix(path, ".webmanifest") ||
|
||||||
|
strings.HasPrefix(path, "/api/") {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для всех остальных путей (в основном HTML) применяем авторизацию
|
||||||
|
authMiddleware(pass)(c)
|
||||||
|
}
|
||||||
|
}
|
144
back/cmd/root.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"port-knocker/internal"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
configFile string
|
||||||
|
keyFile string
|
||||||
|
verbose bool
|
||||||
|
waitConnection bool
|
||||||
|
targetsInline string
|
||||||
|
defaultDelay string
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "port-knocker",
|
||||||
|
Short: "Утилита для отправки port knocking пакетов",
|
||||||
|
Long: `Port Knocker - утилита для отправки TCP/UDP пакетов на определенные порты
|
||||||
|
в заданной последовательности для активации портов на удаленных серверах.
|
||||||
|
|
||||||
|
Поддерживает:
|
||||||
|
- TCP и UDP протоколы
|
||||||
|
- Зашифрованные конфигурационные файлы
|
||||||
|
- Автоматическое определение зашифрованных файлов
|
||||||
|
- Ключи шифрования из файла или системной переменной
|
||||||
|
- Настройка шлюза для отправки пакетов
|
||||||
|
- Гибкая настройка ожидания соединения
|
||||||
|
- Инлайн задание целей без конфигурационного файла`,
|
||||||
|
RunE: runKnock,
|
||||||
|
}
|
||||||
|
|
||||||
|
func Execute() error {
|
||||||
|
return rootCmd.Execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "Путь к файлу конфигурации")
|
||||||
|
rootCmd.PersistentFlags().StringVarP(&keyFile, "key", "k", "", "Путь к файлу ключа шифрования")
|
||||||
|
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Подробный вывод")
|
||||||
|
rootCmd.PersistentFlags().BoolVarP(&waitConnection, "wait-connection", "w", false, "Ждать установления соединения (по умолчанию не ждать)")
|
||||||
|
rootCmd.PersistentFlags().StringVarP(&targetsInline, "targets", "t", "", "Инлайн цели в формате [proto]:[host]:[port];[proto]:[host]:[port]")
|
||||||
|
rootCmd.PersistentFlags().StringVarP(&defaultDelay, "delay", "d", "1s", "Задержка между пакетами (по умолчанию 1s)")
|
||||||
|
|
||||||
|
// НЕ делаем config глобально обязательным - проверяем в runKnock
|
||||||
|
}
|
||||||
|
|
||||||
|
func runKnock(cmd *cobra.Command, args []string) error {
|
||||||
|
// Проверяем что указан либо config файл, либо инлайн цели
|
||||||
|
if configFile == "" && targetsInline == "" {
|
||||||
|
return fmt.Errorf("необходимо указать либо файл конфигурации (-c), либо инлайн цели (-t)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if configFile != "" && targetsInline != "" {
|
||||||
|
return fmt.Errorf("нельзя одновременно использовать файл конфигурации (-c) и инлайн цели (-t)")
|
||||||
|
}
|
||||||
|
|
||||||
|
knocker := internal.NewPortKnocker()
|
||||||
|
|
||||||
|
// Если используем инлайн цели
|
||||||
|
if targetsInline != "" {
|
||||||
|
config, err := parseInlineTargets(targetsInline, defaultDelay)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ошибка разбора инлайн целей: %w", err)
|
||||||
|
}
|
||||||
|
return knocker.ExecuteWithConfig(config, verbose, waitConnection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Иначе используем файл конфигурации
|
||||||
|
return knocker.Execute(configFile, keyFile, verbose, waitConnection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseInlineTargets разбирает строку инлайн целей в Config
|
||||||
|
func parseInlineTargets(targetsStr, delayStr string) (*internal.Config, error) {
|
||||||
|
// Парсим задержку
|
||||||
|
delay, err := time.ParseDuration(delayStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("неверная задержка '%s': %w", delayStr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := &internal.Config{
|
||||||
|
Targets: []internal.Target{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разбиваем по точкам с запятой
|
||||||
|
targetParts := strings.Split(targetsStr, ";")
|
||||||
|
|
||||||
|
for _, targetStr := range targetParts {
|
||||||
|
targetStr = strings.TrimSpace(targetStr)
|
||||||
|
if targetStr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разбираем формат [proto]:[host]:[port]
|
||||||
|
parts := strings.Split(targetStr, ":")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return nil, fmt.Errorf("неверный формат цели '%s', ожидается [proto]:[host]:[port]", targetStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol := strings.TrimSpace(parts[0])
|
||||||
|
host := strings.TrimSpace(parts[1])
|
||||||
|
portStr := strings.TrimSpace(parts[2])
|
||||||
|
|
||||||
|
// Проверяем протокол
|
||||||
|
if protocol != "tcp" && protocol != "udp" {
|
||||||
|
return nil, fmt.Errorf("неподдерживаемый протокол '%s' в цели '%s'", protocol, targetStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим порт
|
||||||
|
port, err := strconv.Atoi(portStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("неверный порт '%s' в цели '%s': %w", portStr, targetStr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if port < 1 || port > 65535 {
|
||||||
|
return nil, fmt.Errorf("порт %d вне допустимого диапазона (1-65535) в цели '%s'", port, targetStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем цель
|
||||||
|
target := internal.Target{
|
||||||
|
Host: host,
|
||||||
|
Ports: []int{port},
|
||||||
|
Protocol: protocol,
|
||||||
|
Delay: internal.Duration(delay),
|
||||||
|
WaitConnection: false,
|
||||||
|
Gateway: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Targets = append(config.Targets, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.Targets) == 0 {
|
||||||
|
return nil, fmt.Errorf("не найдено ни одной валидной цели")
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
70
back/cmd/serve.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"port-knocker/internal"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/cors"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed public/*
|
||||||
|
var embeddedFS embed.FS
|
||||||
|
|
||||||
|
var serveCmd = &cobra.Command{
|
||||||
|
Use: "serve",
|
||||||
|
Short: "Запуск встроенного веб-сервера с GUI и REST API",
|
||||||
|
RunE: runServe,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(serveCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runServe(cmd *cobra.Command, args []string) error {
|
||||||
|
pass := os.Getenv("GO_KNOCKER_SERVE_PASS")
|
||||||
|
if strings.TrimSpace(pass) == "" {
|
||||||
|
return fmt.Errorf("GO_KNOCKER_SERVE_PASS не задан — задайте пароль для доступа к GUI/API")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Хеш, который будем использовать как ключ шифрования (совместимо с internal)
|
||||||
|
passHash := sha256.Sum256([]byte(pass))
|
||||||
|
// Пробрасываем пароль; internal сам выполнит sha256 от значения env
|
||||||
|
os.Setenv(internal.EncryptionKeyEnvVar, pass)
|
||||||
|
|
||||||
|
port := os.Getenv("GO_KNOCKER_SERVE_PORT")
|
||||||
|
if strings.TrimSpace(port) == "" {
|
||||||
|
port = "8888"
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.Default()
|
||||||
|
|
||||||
|
// CORS: разрешаем для локальной разработки
|
||||||
|
r.Use(cors.New(cors.Config{
|
||||||
|
AllowOrigins: []string{"http://localhost:4200", "http://127.0.0.1:8888", "http://localhost:8888"},
|
||||||
|
AllowMethods: []string{"GET", "POST", "OPTIONS"},
|
||||||
|
AllowHeaders: []string{"Authorization", "Content-Type"},
|
||||||
|
AllowCredentials: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Применяем middleware для защиты HTML страниц
|
||||||
|
r.Use(staticAuthMiddleware(pass))
|
||||||
|
|
||||||
|
// API роуты с авторизацией
|
||||||
|
api := r.Group("/api/v1/knock-actions")
|
||||||
|
api.Use(authMiddleware(pass))
|
||||||
|
|
||||||
|
// Настраиваем роуты
|
||||||
|
setupKnockRoutes(api)
|
||||||
|
setupCryptoRoutes(api, passHash)
|
||||||
|
setupStaticRoutes(r, embeddedFS)
|
||||||
|
|
||||||
|
fmt.Printf("Serving on :%s\n", port)
|
||||||
|
return r.Run(":" + port)
|
||||||
|
}
|
107
back/cmd/static_routes.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// embeddedFS объявлен в serve.go
|
||||||
|
|
||||||
|
// setupStaticRoutes настраивает роуты для статических файлов
|
||||||
|
func setupStaticRoutes(r *gin.Engine, embeddedFS embed.FS) {
|
||||||
|
// Получаем подфайловую систему для public
|
||||||
|
sub, err := fs.Sub(embeddedFS, "public")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчик для всех остальных маршрутов (статические файлы)
|
||||||
|
r.NoRoute(func(c *gin.Context) {
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
|
||||||
|
// Убираем ведущий слеш для fs.Sub
|
||||||
|
path = strings.TrimPrefix(path, "/")
|
||||||
|
|
||||||
|
// Если путь пустой, показываем index.html
|
||||||
|
if path == "" {
|
||||||
|
path = "index.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем файл из встроенной файловой системы
|
||||||
|
data, err := fs.ReadFile(sub, path)
|
||||||
|
if err != nil {
|
||||||
|
// Если файл не найден, показываем index.html (SPA routing)
|
||||||
|
if strings.Contains(path, ".") {
|
||||||
|
// Это файл с расширением, возвращаем 404
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Это маршрут SPA, показываем index.html
|
||||||
|
data, err = fs.ReadFile(sub, "index.html")
|
||||||
|
if err != nil {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем Content-Type по расширению
|
||||||
|
contentType := getContentType(path)
|
||||||
|
c.Header("Content-Type", contentType)
|
||||||
|
|
||||||
|
// Специальные заголовки для шрифтов
|
||||||
|
if isFontFile(path) {
|
||||||
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Header("Cache-Control", "public, max-age=31536000")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data(http.StatusOK, contentType, data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getContentType определяет Content-Type по расширению файла
|
||||||
|
func getContentType(path string) string {
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
switch ext {
|
||||||
|
case ".html":
|
||||||
|
return "text/html; charset=utf-8"
|
||||||
|
case ".css":
|
||||||
|
return "text/css; charset=utf-8"
|
||||||
|
case ".js":
|
||||||
|
return "application/javascript; charset=utf-8"
|
||||||
|
case ".json":
|
||||||
|
return "application/json; charset=utf-8"
|
||||||
|
case ".png":
|
||||||
|
return "image/png"
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
return "image/jpeg"
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif"
|
||||||
|
case ".svg":
|
||||||
|
return "image/svg+xml"
|
||||||
|
case ".ico":
|
||||||
|
return "image/x-icon"
|
||||||
|
case ".woff":
|
||||||
|
return "font/woff"
|
||||||
|
case ".woff2":
|
||||||
|
return "font/woff2"
|
||||||
|
case ".ttf":
|
||||||
|
return "font/ttf"
|
||||||
|
case ".eot":
|
||||||
|
return "application/vnd.ms-fontobject"
|
||||||
|
case ".webmanifest":
|
||||||
|
return "application/manifest+json"
|
||||||
|
default:
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isFontFile проверяет, является ли файл шрифтом
|
||||||
|
func isFontFile(path string) bool {
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
return ext == ".woff" || ext == ".woff2" || ext == ".ttf" || ext == ".eot"
|
||||||
|
}
|
22
back/examples/config-2.yaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
path: /home/su/projects/articles/embed-gui-article/back/examples/config.yaml
|
||||||
|
targets:
|
||||||
|
- host: "192.168.1.100"
|
||||||
|
ports: [1000, 2000, 3000]
|
||||||
|
protocol: "tcp"
|
||||||
|
delay: "1s"
|
||||||
|
wait_connection: false
|
||||||
|
gateway: ""
|
||||||
|
|
||||||
|
- host: "10.0.0.50"
|
||||||
|
ports: [5000, 6000, 7000, 8000]
|
||||||
|
protocol: "udp"
|
||||||
|
delay: "500ms"
|
||||||
|
wait_connection: false
|
||||||
|
gateway: "192.168.1.1"
|
||||||
|
|
||||||
|
- host: "example.com"
|
||||||
|
ports: [22, 80, 443]
|
||||||
|
protocol: "tcp"
|
||||||
|
delay: "2s"
|
||||||
|
wait_connection: false
|
||||||
|
gateway: "10.0.0.1:8080"
|
22
back/examples/config.yaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
path: /home/su/projects/articles/embed-gui-article/back/examples/config.yaml
|
||||||
|
targets:
|
||||||
|
- host: "192.168.1.100"
|
||||||
|
ports: [1000, 2000, 3000]
|
||||||
|
protocol: "tcp"
|
||||||
|
delay: "1s"
|
||||||
|
wait_connection: false
|
||||||
|
gateway: ""
|
||||||
|
|
||||||
|
- host: "10.0.0.50"
|
||||||
|
ports: [5000, 6000, 7000, 8000]
|
||||||
|
protocol: "udp"
|
||||||
|
delay: "500ms"
|
||||||
|
wait_connection: false
|
||||||
|
gateway: "192.168.1.1"
|
||||||
|
|
||||||
|
- host: "example.com"
|
||||||
|
ports: [22, 80, 443]
|
||||||
|
protocol: "tcp"
|
||||||
|
delay: "2s"
|
||||||
|
wait_connection: true
|
||||||
|
gateway: "10.0.0.1:8080"
|
41
back/go.mod
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
module port-knocker
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-contrib/cors v1.7.2
|
||||||
|
github.com/gin-gonic/gin v1.10.0
|
||||||
|
github.com/spf13/cobra v1.8.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
|
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
|
||||||
|
)
|
107
back/go.sum
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||||
|
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
|
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
|
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||||
|
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
||||||
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||||
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||||
|
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
|
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||||
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
|
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||||
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||||
|
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
561
back/internal/jokes.md
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
На собеседовании в школе для особо одарённых детей шестилетнего Вовочку попросили рассказать, чем автобус отличается от троллейбуса.
|
||||||
|
Вовочка ничего скрывать от тёти не стал и честно ей сообщил, что автобус работает на двигателе внутреннего сгорания, а троллейбус - на электродвигателе переменного тока.
|
||||||
|
Оказалось - ничего подобного!
|
||||||
|
Просто троллейбус с рогами, а автобус - без.
|
||||||
|
И нечего тут морочить тёте голову!
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Гламурная москвичка приезжает погостить в деревню к бабушке.
|
||||||
|
|
||||||
|
- Бабуль, куда у вас тут ночью сходить можно?
|
||||||
|
- В ведро.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Только перечитывая в 40 лет книгу "Д'Артаньян и три мушкетёра" ты наконец-то начинаешь понимать, что единственный положительный герой в этой книге - кардинал Ришелье...
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Вместо того, чтобы у себя в Алжире, Марокко и Тунисе строить жизнь, как во Франции, люди приезжают во Францию, чтобы там создать себе такую жизнь, как в Алжире, Марокко и Тунисе. Это странно. Ещё более странно заставлять при этом и французов жить, как в Алжире, Марокко и Тунисе.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Твоя религия ничего МНЕ не запрещает. Она запрещает ТЕБЕ. Уясни это.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Антон Силуанов предложил россиянам не думать о ключевой ставке ЦБ.
|
||||||
|
Также россиянам не стоит думать о:
|
||||||
|
|
||||||
|
- процентах по кредитам;
|
||||||
|
- курсе рубля;
|
||||||
|
- тарифах ЖКХ;
|
||||||
|
- ценах в магазинах.
|
||||||
|
В то же время россиянам стоит думать о:
|
||||||
|
- госдолге США;
|
||||||
|
- крахе доллара;
|
||||||
|
- величии державы;
|
||||||
|
- необходимости потерпеть.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Василий Иванович с Петькой сидят, скучают. Василий Иванович:
|
||||||
|
|
||||||
|
- Петька, сгоняй на хутор к старику-самогонщику, сообрази чего-нибудь!
|
||||||
|
- Это можно.
|
||||||
|
Через час Петька возвращается. Василий Иванович:
|
||||||
|
- Ну как?
|
||||||
|
- Да нет у него ни фига.
|
||||||
|
- Эх, молодежь, всему вас учить надо. Пойдем вместе.
|
||||||
|
Приходят к деду. Василий Иванович:
|
||||||
|
- Здорово, отец!
|
||||||
|
- Здорово, сынки!
|
||||||
|
- Мы вот к тебе от имени Советской власти. Вот я гляжу, хата у тебя старая.
|
||||||
|
- Ох, какая старая!
|
||||||
|
- Петька, запиши: новую хату ему от Советской власти! И жена у тебя вроде старая...
|
||||||
|
- Ой, какая старая!!
|
||||||
|
- Петька, запиши: новую жену ему от Советской власти!
|
||||||
|
- Сынки! Родненькие! Да сколько ж я вас ждал!
|
||||||
|
Ну, сели за стол, выпили, с собой взяли. Уже в дверях Василий Иванович как бы невзначай:
|
||||||
|
- Слышь, дед! А может у тебя и /хрен/ старый?
|
||||||
|
- Уж каккой старый!!!
|
||||||
|
- Петька, запиши: /хрен/ ему от Советской власти!
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Таможенник поднимается на корабль для досмотра.
|
||||||
|
|
||||||
|
- Наркотики есть?
|
||||||
|
Хозяин корабля отвечает:
|
||||||
|
- Есть. Вот, пожалуйста (достает чемодан). Вот героин, вот кокаин. Все аккуратно упаковано,вот шприц готовый.
|
||||||
|
Таможенник, вытаращив глаза:
|
||||||
|
- А может, и оружие есть?
|
||||||
|
Хозяин (достает другой чемодан):
|
||||||
|
- Вот Макаров, вот Калашник ,вот патроны к ним. Все как надо.
|
||||||
|
Таможенник с усмешкой:
|
||||||
|
- Наверное, и валюта есть?
|
||||||
|
Хозяин достает третий чемодан:
|
||||||
|
- Вот миллион долларов, пожалуйста.
|
||||||
|
Таможенник, ничего не понимая:
|
||||||
|
- И это все ваше?
|
||||||
|
Хозяин:
|
||||||
|
- Нет, это ваше. Мое в трюме.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Летит самолет. Пилот по громкой связи:
|
||||||
|
|
||||||
|
- Уважаемые дамы и господа, вас приветствует командир корабля. Прослушайте информацию о нашем полете. Наш полет проходит на высоте 10 тысяч метров со скоростью 900 километров в час, температура за бортом...БЛИН...А-А-А-А!.. ЭТО ЧТО ТАКОЕ?.. НУ, КРЫНДЕЦ...
|
||||||
|
В салоне гробовая тишина.
|
||||||
|
Через минуту опять по радио:
|
||||||
|
- Прошу извинения у уважаемых пассажиров. Просто это наша стюардесса опрокинула на меня горячий кофе. Видели ли бы вы теперь мои белые брюки спереди...
|
||||||
|
Мужик в первом ряду:
|
||||||
|
- Твои брюки, это все херня! Видел бы ты мои брюки сзади...
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Новый учитель, придя в класс, обнаружил, что одного мальчика дразнят Мойше-дурачок. На перемене он спросил ребят, почему они его так обзывают.
|
||||||
|
|
||||||
|
- Да он и вправду дурачок, господин учитель. Если дать ему большую монету в пять шекелей и маленькую в десять, он выберет пять, потому что думает, что она больше. Вот, смотрите...
|
||||||
|
Парень достает две монеты и предлагает Мойше выбрать. Тот, как всегда,
|
||||||
|
выбирает пять. Учитель с удивлением спращивает:
|
||||||
|
- Почему же ты выбрал монету в пять шекелей, а не в десять?
|
||||||
|
- Посмотрите, она же больше, господин учитель!
|
||||||
|
После уроков учитель подошел к Мойше.
|
||||||
|
- Неужели ты не понимаешь, что пять шекелей больше только по размерам,
|
||||||
|
но на десять шекелей можно купить больше?
|
||||||
|
- Конечно понимаю, господин учитель.
|
||||||
|
- Так почему же ты выбираешь пять?
|
||||||
|
- Потому что, если я выберу десять, они перестанут давать мне деньги!
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Как разные народы переносят низкие температуры:
|
||||||
|
+10 C: Американцев трясет. Русские сажают огурцы в огородах.
|
||||||
|
+1.6 C: У итальянцев не заводятся машины. Русские ездят с опущенными стеклами.
|
||||||
|
0 C: В Америке замерзает вода. В России вода загустевает.
|
||||||
|
|
||||||
|
- 17.9 C: В Нью-Йорке домовладельцы включают отопление. Русские последний раз в сезоне выезжают на пикники.
|
||||||
|
- 42 C: В Европе не функционирует транспорт. Русские едят мороженое на улице.
|
||||||
|
- 73 C: Финский спецназ эвакуирует Санта-Клауса из Лапландии. Русские надевают ушанки.
|
||||||
|
- 114 C: Замерзает этиловый спирт. У русских плохое настроение.
|
||||||
|
- 273 C: Абсолютный ноль, остананавливается атомарное движение. Русские ругаются: "Холодно, мля!"
|
||||||
|
- 295 C: У католиков в аду замерзают черти. Российская сборная по футболу становится чемпионом мира.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
На дискотеке в Германии русский в майке с надписью: "У турков три проблемы".
|
||||||
|
К нему тут же подходит турок и спрашивает:
|
||||||
|
|
||||||
|
- Ты чего? Проблем ищешь? Ты наехать хочешь?
|
||||||
|
- Это ваша первая проблема. Агрессивность. Вы всегда пытаетесь создавать проблемы на пустом месте.
|
||||||
|
Когда дискотека заканчивается, то русского уже подкарауливает группа
|
||||||
|
турков.
|
||||||
|
- Сейчас ты ответишь за свои слова! - говорят они.
|
||||||
|
- Это ваша вторая проблема. Вы не можете решать свои проблемы сами и сразу собираете своих по любому поводу.
|
||||||
|
- Да как ты смеешь с нами так говорить?!! - турки повыхватывали ножи...
|
||||||
|
- Это ваша третья проблема, - продолжает русский. - Вы всегда приходите с ножами на перестрелку.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
ДЕЛОВОЕ ПРЕДЛОЖЕНИЕ
|
||||||
|
(пер. с англ, автор мне неизвестен)
|
||||||
|
Джонни очень хотел одну девушку в своем офисе, но она принадлежала другому... Как-то раз ему стало так невмоготу, что он подошел к ней и сказал: "Я
|
||||||
|
дам тебе 1000 долларов, если ты мне отдашься", но девушка ответила "НЕТ".
|
||||||
|
Джонни сказал: "Да я быстро - я брошу деньги на пол, ты нагнешься подобрать, а как поднимешь - я уже закончу". Девушка задумалась на секунду, и ответила, что должна проконсультироваться с бойфрендом.
|
||||||
|
|
||||||
|
Она позвонила и рассказала тому все. Бойфренд ответил: "Проси 2000, и поднимай деньги очень быстро, так чтоб он даже не успел спустить штаны".
|
||||||
|
Девушка согласилась, и дала свое согласие Джонни.
|
||||||
|
|
||||||
|
Прошло полчаса, бойфренд ждет, а девушка все не звонит... Наконец спустя 45 минут бойфренд позвонил сам и спросил, что случилось. Девушка ответила: "Этот подонок расплатился монетами."
|
||||||
|
|
||||||
|
МОРАЛЬ: Всегда рассматривайте деловое предложение досконально, до того, как вы его примете и вас поимеют!
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Начало учебного года в американской школе. Классная руководительница знакомит класс:
|
||||||
|
|
||||||
|
- Дети, у нас новенький – Шакиро Сузуки из Японии, знакомьтесь. А сейчас начинаем урок и посмотрим, как хорошо вы знаете американскую историю.
|
||||||
|
Кто сказал "Свобода или смерть"?
|
||||||
|
В классе мертвая тишина. Сузуки вскидывает руку:
|
||||||
|
- Патрик Генри, 1775 год, Филадельфия.
|
||||||
|
- Очень хорошо. А чьи слова: "Государство – это народ, и как таковое никогда не должно умереть"?
|
||||||
|
Опять рука Сузуки:
|
||||||
|
- Абрахам Линкольн, 1863 год, Вашингтон.
|
||||||
|
Учительница строго смотрит на класс:
|
||||||
|
- Стыдно, дети! Сузуки – японец, а знает американскую историю лучше всех!
|
||||||
|
В этот момент тихий голос с задней парты:
|
||||||
|
- Задолбали сраные япошки!
|
||||||
|
Учительница резко оборачивается:
|
||||||
|
- Кто сказал???!!!
|
||||||
|
Сузуки вскакивает и оттарабанивает:
|
||||||
|
- Генерал МакАртур, остров Гвадалканал, 1942 год.
|
||||||
|
Возмущенный вопль:
|
||||||
|
- Сузуки – дерьмо!!!
|
||||||
|
И ни секунды задержки:
|
||||||
|
- Валентино Росси на мотогонках ГранПри-Бразилия в Рио де Жанейро, 2002
|
||||||
|
год! –выпаливает японец!
|
||||||
|
Класс в истерике, училка в обмороке, распахивается дверь и появляется
|
||||||
|
разъяренный директор школы:
|
||||||
|
- Вашу мать! Что здесь за бардак???!!!
|
||||||
|
Не успевший сесть Сузуки:
|
||||||
|
- Президент Ельцин, заседание парламента России, 1993 год!
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Муж:
|
||||||
|
|
||||||
|
- Какого тёща приезжает?
|
||||||
|
Жена:
|
||||||
|
- Числа или хрена?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Лозунг "Задушим коррупцию" был признан экстремистским как призывающий к
|
||||||
|
насильственному свержению существующего строя.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Урок "Основы православной культуры". Учительница:
|
||||||
|
|
||||||
|
- И помните, дети! Те, кто будет учиться на "4" и "5", попадут в рай. А
|
||||||
|
те, кто будет учиться на "2" и "3", - в ад!
|
||||||
|
Вовочка с задней парты:
|
||||||
|
- Мариванна, а что, закончить школу живым нельзя?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Штаб Ку Клукс Клана:
|
||||||
|
|
||||||
|
- Скажите, как вступить в вашу организацию?
|
||||||
|
- Это просто. Нужно замочить 6 негров и одного кота.
|
||||||
|
- А кота за что?
|
||||||
|
- Поздравляю, вы приняты
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Увидев на холодильнике всего два магнитика - из Магадана и Воркуты, воры покормили кота и вымыли посуду.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Боевик ИГИЛ остановил автомобиль христианской пары.
|
||||||
|
Боевик ИГИЛ: «Ты мусульманин?»
|
||||||
|
Христианин: «Да, я мусульманин».
|
||||||
|
Боевик ИГИЛ: «Если ты мусульманин, перескажи суру из Корана».
|
||||||
|
Христианин рассказал стихотворение из Библии.
|
||||||
|
Боевик ИГИЛ: «Хорошо, можешь ехать».
|
||||||
|
Через несколько минут жена, едва переведя дух, говорит мужу: «Не могу поверить, как ты пошел на такой риск. Почему ты сказал, что мы мусульмане? Если бы он узнал, что ты врёшь, он убил бы нас обоих!»
|
||||||
|
«Зря волновалась. Если бы они знали Коран, они бы никогда не убивали людей!» – ответил ей муж.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Сборная России взяла 4 золота на международной олимпиаде по физике в Цюрихе. Деньги и белые BMW никто не предложил. Даже не заметили.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Когда в стране коррупции нет — микролитражки мчат по хайвеям.
|
||||||
|
Когда коррупция — Бентли тащатся по бездорожью.
|
||||||
|
Всё просто, брат.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Интересно, а если провести обыск у всего руководства ФСБ - можно будет обратно понизить пенсионный возраст?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Центр организации дорожного движения Москвы пришел к выводу, что личный автомобиль гражданину не нужен. 93% времени он стоит на приколе, а лишь 7% используется, заявил руководитель Департамента транспорта Максим Ликсутов.
|
||||||
|
Остроумные люди посоветовали Максиму Ликсутову отрезать пенис, которым он пользуется меньше 10 минут в день.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
По поводу слов Кадырова, что его достало, что во всем обвиняют кавказцев... Знакомый татарин сказал: До тех пор пока ты ЧЕЛОВЕК, никого в России твоя национальность особо не интересует. Как только стал СКОТИНОЙ всем сразу интересно, чья это скотина гадит?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Зачем пересаживать чиновников на отечественные автомобили, если они имеют право на бесплатный проезд в общественном транспорте?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Почему те, кто хочет носить хиджаб не живут там, где его ношение приветствуется?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
В кафе заходит человек с собакой и заключает с посетителями пари,что его пес сейчас будет разговаривать. Но собака молчит. Человек оплачивает пари и уходит под общий хохот.
|
||||||
|
|
||||||
|
- Из-за тебя я проиграл уйму денег! - говорит хозяин собаке. - Почему ты не заговорил?
|
||||||
|
- Чудак! - отвечает пес. - Ты только представь, сколько денег мы загребем завтра.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Выходит утром гаишник на дорогу, голова после вчерашнего раскалывается.
|
||||||
|
Смотрит - джип несется. Ну он остановил его с целью сбора средств на опохмел. Смотрит, а там бомж сидит. Документы проверил - правда, бомжа машина. Ну мент его спрашивает:
|
||||||
|
|
||||||
|
- Ты же бомж. Ты где такую крутую тачку взял?
|
||||||
|
- А мне пьяные новые русские предложили, если я их рассмешу - джип мой. Ну я их и рассмешил.
|
||||||
|
- А как?
|
||||||
|
- Да я одному лысому на голову нагадил, у него сразу волосы выросли, вот умора была.
|
||||||
|
Мент шапку снимает, там лысина. Он и говорит:
|
||||||
|
- А ты мне так можешь?
|
||||||
|
- Могу.
|
||||||
|
Бомж гадит менту на лысину, a из кустов раздается хохот и крик:
|
||||||
|
- Не, ну ваще, да я ему еще и хату подарю.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Вовочка приходит в аптеку:
|
||||||
|
|
||||||
|
- Дайте мне упаковку презервативов!
|
||||||
|
- Во-первых, это не для детей, - отвечает аптекарь, - а во-вторых, пусть придет папа и возьмет нужный размер.
|
||||||
|
- Во-первых, это не для детей, а от детей, а во-вторых, это не для папы, а мама едет на курорт, и какие там размеры будут, она еще не знает...
|
||||||
|
|
||||||
|
Кенийский бегун Абель Мутай был всего в нескольких футах от финиша, но перепутал с вывесками и остановился, думая, что завершил гонку. Испанский бегун, Иван Фернандес, стоял за ним и, понимая, что происходит, начал кричать на кенийца, чтобы он продолжил бег. Мутай не знал испанского и не понял. Понимая, что происходит, Фернандес толкнул Мутая к победе. Журналист спросил Ивана: «Зачем ты это сделал?» Иван ответил: «Моя мечта заключается в том, чтобы когда-нибудь у нас была такая общественная жизнь, где мы толкаемся и помогаем друг другу побеждать». Журналистка настаивала: «Но почему ты дал победить Кении?» Иван ответил:«Я не дал ему победить, он собирался победить. Гонка была его». Журналист настаивал, и снова спросил: «А ведь можно было победить!» Иван посмотрел на него и ответил: «А в чем заслуга моей победы? Какая честь будет в этой медали? Что бы моя мама об этом подумала? Ценности передаются из поколения в поколение. Каким ценностям мы учим наших детей?»
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Попали в Ад американец, индус и русский. Встретил их Черт и говорит:
|
||||||
|
|
||||||
|
- Всем, кто сюда попадает, даю шанс перейти в Рай.
|
||||||
|
И достает здоровенный кнут (побольше, чем у Харрисона Форда в "Последнем
|
||||||
|
крестовом походе"):
|
||||||
|
- Кто выдержит три удара не закричав - отпускаю! Можете защищаться, чем хотите.
|
||||||
|
Первым вышел американец.
|
||||||
|
- Чем хочешь защищаться?
|
||||||
|
Американец взял здоровый гранитный камень:
|
||||||
|
- Я готов!
|
||||||
|
Черт размахнулся в первый раз и... камень вдребезги. Второй раз - и американец заорал как бешенный...
|
||||||
|
- Следующий, - говорит Черт.
|
||||||
|
Выходит индус.
|
||||||
|
- Чем будешь защищаться?
|
||||||
|
- Ничем! - отвечает индус, - Я 80 лет занимался йогой, и в медитации тело не чувствует боли!
|
||||||
|
- Ладно.
|
||||||
|
Первый удар. Индус: - Ошшш...
|
||||||
|
Второй удар. Индус: - Ошшш...
|
||||||
|
Третий удар. Индус: - Ошшш...
|
||||||
|
- Ух е# твою... Еще никто не выдерживал трех ударов. - говорит Черт. - Ну
|
||||||
|
что ж, ты свободен, можешь спокойно идти в Рай.
|
||||||
|
- Нет, - говорит индус, - хочу остаться и посмотреть. Во всех анекдотах русские выигрывают. Хочу увидеть, как у него на этот раз получится.
|
||||||
|
- Ладно, останься. Ну, чем думаешь защищаться? - обращается Черт к русскому.
|
||||||
|
- Чем защищаться - индусом, конечно...
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Отвечать надо быстро, не раздумывая и не тратя понапрасну время.
|
||||||
|
А главное - не мошенничать!
|
||||||
|
|
||||||
|
1. Вы участвуете в соревнованиях и обогнали бегуна, занимающего вторую
|
||||||
|
позицию. Какую позицию вы теперь занимаете?
|
||||||
|
Ответ: Если вы ответили, что вы теперь первый - то вы абсолютно не
|
||||||
|
правы.
|
||||||
|
Вы обогнали второго бегуна и заняли его место, так что вы теперь на
|
||||||
|
второй позиции.
|
||||||
|
Попробуйте не ошибиться во втором вопросе.
|
||||||
|
2. Вы обогнали последнего бегуна, на какой позиции вы теперь находитесь?
|
||||||
|
Ответ: Если вы ответили на предпоследнем - вы опять абсолютно не правы.
|
||||||
|
Подумайте. Как можно обогнать бегуна, идущего последним? Если вы бежите
|
||||||
|
за ним, значит он не последний. Ответ - это невозможно. Получается, что
|
||||||
|
использование мозга ваша не самая сильная сторона.
|
||||||
|
Как бы то ни было - вот еще один вопрос. Ничего не пишите и не
|
||||||
|
используйте калькулятор, и помните - вы должны отвечать быстро.
|
||||||
|
Возьмите 1000. Прибавьте 40. Прибавьте еще тысячу. Прибавьте 30.
|
||||||
|
Еще 1000.
|
||||||
|
Плюс 20. Плюс 1000. И плюс 10. Что получилось?
|
||||||
|
Ответ 5000? Опять неверно. Правильный ответ 4100. Попробуйте пересчитать
|
||||||
|
на калькуляторе.
|
||||||
|
Сегодня точно не ваш день. Но, может быть, получится с последним вопросом.
|
||||||
|
У отца Мэри есть пять дочерей: 1. Чача 2. Чече 3. Чичи 4 Чочо.
|
||||||
|
Вопрос: Как зовут пятую дочь? Думайте быстро. Ответ чуть ниже.
|
||||||
|
Ответ: Чучу? НЕТ! Конечно, ее зовут Мэри. Прочтите еще раз вопрос.
|
||||||
|
ВЫВОД: Вы самое слабое звено - прощайте.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Мужик просыпается с утра с жуткого бодуна, открывает глаза, голова болит, оглядывается по сторонам: фуууу, дома... встает с кровати, ощупывает себя - е-мое, в пижаме... в жизни пижаму не одевал. Смотрит - на туалетном столике стакан воды, таблетка аспирина и записка от жены:
|
||||||
|
"Милый, завтрак на столе, все прибрала, твоя навеки - жена". Мужик в совершенном непонимании, выпивает таблетку и идет в ванную... по пути обнаруживает, что квартира не то что чистая, просто вылизана до блеска, сын сидит у себя в комнате, делает уроки...
|
||||||
|
|
||||||
|
- Сынок, а что вчера было?
|
||||||
|
- Ты пришел пьяный, как обычно под утро. Облевал всю прихожую, нагадил мимо унитаза, побил в кухне всю посуду, поставил матери фингал под глазом.
|
||||||
|
- Ну и, что случилось с мамой, с квартирой???
|
||||||
|
- Ааа, ты про это, просто когда тебя мама стала укладывать спать и начала стягивать с тебя штаны, ты заорал "уйди, сука - Я ЖЕНАТЫЙ!!!"
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Штатский Джонс был назначен в армейский учебный центр, где он должен был просвещать рекрутов по поводу различных правительственных обязательств перед ними, особенно о Страховании Жизни Военослужащих (СЖВ). Вскоре после этого лейтенант центра заметил, что Джонс имеет почти 100%-ю продажу страховок СЖВ, чего раньше никогда не бывало. Лейтенант сел в конце заполненной рекрутами комнаты и стал слушать торговую подачу Джонса. Джонс объяснил новым рекрутам основы СЖВ, а затем сказал:
|
||||||
|
"Если у вас есть СЖВ и вы пошли в бой и погибли, - правительство обязано выплатить вашим наследникам 200 000$. Если у вас нет СЖВ и вы пошли в бой и погибли, - правительство обязано выплатить вашим наследникам максимум всего лишь 6000$". "А теперь", сказал он в заключение, "как вы думаете, кого они пошлют в бой первыми?"
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
- Ватсон, а что это вы курите? Дайте угадаю - табак "Королева Вирджиния"
|
||||||
|
с листочками вишни, из юбилейного выпуска в бархатной упаковке?
|
||||||
|
- Поразительно, Холмс! Как это вы угадали?
|
||||||
|
- Ей-богу, Ватсон! Ну не миссис Хадсон же свистнула из моей комнаты
|
||||||
|
последнюю пачку!
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
У последней остановки метро ждет автобуса инженер, который допоздна
|
||||||
|
делал халтуру на работе. Полдвенадцатого ночи. Автобуса нет. Он весь
|
||||||
|
задубел... И тут возле него останавливается шикарный Лексус, опускается
|
||||||
|
окно и девушка типа “порномодель” говорит: "Садитесь, я вас подвезу". Он
|
||||||
|
отнекивается, мол денег нету...Она: "Да какие деньги! Вы ж на бирюлевский
|
||||||
|
автобус тут стоите... А как они ходят?! Садитесь, я так вас подвезу, а то
|
||||||
|
замерзнете..."
|
||||||
|
Он сел назад. Поехали. Тепло. Класс. И тут она спрашивает:
|
||||||
|
|
||||||
|
- Ничего, если мы за подружкой моей заедем? Я с ней раньше
|
||||||
|
договаривалась. Но это по пути... Пара минут...
|
||||||
|
Он говорит:
|
||||||
|
- Конечно... Хозяин-барин. Какие вопросы...
|
||||||
|
Заехали. Выходит девушка такого же калибра, как и первая. Плюхается на
|
||||||
|
сиденье и говорит:
|
||||||
|
- Мань, я похавать не успела. Давай причалим к магазинчику хавки купим...
|
||||||
|
Причалили... Та зашла... Выходит. У нее 2 бутылки французского шампанского
|
||||||
|
по штуке баксов, сувенирное (на полкило) ведерочко черной икры,
|
||||||
|
французские батоны, еще что-то в фирменных коробочках...
|
||||||
|
Едут... Высаживают мужика... И тут та, что со жратвой говорит:
|
||||||
|
- Мань, а что мы тут в машине крошить будем?
|
||||||
|
А та, что за рулем - мужику:
|
||||||
|
- Вы не против, если мы на пять минут к вам зайдем, перекусим и дальше
|
||||||
|
поедем?
|
||||||
|
Он извиняется, что мол, холостяцкий беспорядок, они: “Ничего... Мы
|
||||||
|
ненадолго...”
|
||||||
|
Поднялись к нему. Выпили эти две бутылки. Закусили... И /делали взрослые дела/ втроем до утра.
|
||||||
|
|
||||||
|
А через какое-то время эти телки прохаживаются в Доме кино по какой-то
|
||||||
|
тусовке. И одна говорит:
|
||||||
|
|
||||||
|
- Как все это меня достало! Эти престарелые плейбои, этот Михалков со
|
||||||
|
своими проститутками, этот Гусман старый дедун, эти все заслуженные
|
||||||
|
П***** России... Блин, смотреть уже на них не могу.
|
||||||
|
А вторая:
|
||||||
|
- Слушай, давай плюнем на это все и поедем к Коле в Бирюлево!
|
||||||
|
Первая:
|
||||||
|
- Да-а... К Коле в Бирюлево... Думаешь, он нас вспомнит?
|
||||||
|
|
||||||
|
ПАМЯТНИК ЛАБРАДОРУ МОНТИ *** В городе Квиснсленде (Австралия) жил лабрадор по кличке Монти. Хозяин был глубоко пожилым человеком. Он всегда брал Монти в походы по местным магазинам и обучил его носить в зубах свою корзину с продуктами. Однажды владелец Монти был не в силах пойти по магазинам. Он послал Монти со списком покупок и деньгами в корзине. Монти обошел все магазины, в которые он заходил вместе с хозяином. Продавцы читали записку и клали в корзинку необходимое. С тех пор Монти каждый день бегал по магазинам с корзинкой в зубах. Монти получил такую известность, что когда случилось неизбежное и он умер, местная община решила возвести ему памятник в виде бронзовой статуи с корзинкой, полной продуктов. Теперь, бронзовый Монти в натуральную величину сидит при входе в торговый центр, куда он бегал за продуктами для своего хозяина. Памятник установили 15 июня 1996 года.
|
||||||
|
**********
|
||||||
|
|
||||||
|
Как попасть в рай (притча)
|
||||||
|
По длинной, дикой, утомительной дороге шел человек с собакой.
|
||||||
|
Шел он себе шел, устал, собака тоже устала. Вдруг перед ним - оазис!
|
||||||
|
Прекрасные ворота, за оградой - музыка, цветы, журчание ручья,
|
||||||
|
словом, отдых.
|
||||||
|
|
||||||
|
- Что это такое? - спросил путешественник у привратника.
|
||||||
|
- Это рай, ты уже умер, и теперь можешь войти и отдохнуть
|
||||||
|
по-настоящему.
|
||||||
|
- А есть там вода?
|
||||||
|
- Сколько угодно: чистые фонтаны, прохладные бассейны...
|
||||||
|
- А поесть дадут?
|
||||||
|
- Все, что захочешь.
|
||||||
|
- Но со мной собака.
|
||||||
|
- Сожалею, сэр, с собаками нельзя. Ее придется оставить здесь.
|
||||||
|
И путешественник пошел мимо.. Через некоторое время дорога привела его
|
||||||
|
на ферму. У ворот тоже сидел привратник.
|
||||||
|
- Я хочу пить, - попросил путешественник.
|
||||||
|
- Заходи, во дворе есть колодец.
|
||||||
|
- А моя собака?
|
||||||
|
- Возле колодца увидишь поилку.
|
||||||
|
- А поесть?
|
||||||
|
- Могу угостить тебя ужином.
|
||||||
|
- А собаке?
|
||||||
|
- Найдется косточка.
|
||||||
|
- А что это за место?
|
||||||
|
- Это рай.
|
||||||
|
- Как так? Привратник у дворца неподалеку сказал мне, что рай - там.
|
||||||
|
- Врет он все. Там ад.
|
||||||
|
- Как же вы, в раю, это терпите?
|
||||||
|
- Это нам очень полезно. До рая доходят только те, кто не бросает
|
||||||
|
своих друзей.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Мужик едет на встречу, опаздывает, нервничает, не может найти место
|
||||||
|
припарковаться. Поднимает лицо к небу и говорит:
|
||||||
|
— Господи, помоги мне найти место для парковки. Я тогда брошу пить и
|
||||||
|
буду каждое воскресенье ходить в церковь!
|
||||||
|
Вдруг чудесным образом появляется свободное местечко. Мужик снова
|
||||||
|
обращается к небу:
|
||||||
|
— А, всё, не надо. Нашёл!
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Журналисты спрашивают у фермера:
|
||||||
|
|
||||||
|
- Скажите, как у вас прошел год.
|
||||||
|
- Не поверите, замечательно. Урожай зерна хороший - без хлеба не
|
||||||
|
останусь, картошка удалась - опять таки буду не голодный, а еще свинья
|
||||||
|
опоросилась...
|
||||||
|
- Вы не хотели бы поблагодарить за это президента?
|
||||||
|
- Да с чего ж? Пахал сам, сеял сам, растил и собирал опять таки сам - в
|
||||||
|
чем тут его заслуга.
|
||||||
|
- Как так? (жестко) А вы подумайте!
|
||||||
|
- А, ну ежли подумать, то насчет свиньи не отрицаю, тут всяко могло
|
||||||
|
быть...
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Сомалийский иммигрант прибыл в Берлин. Он останавливает первого человека, которого он видит и говорит: "Благодарю вас, господин. Германия позволила мне жить в этой стране, дала мне жилье, денег на еду, бесплатное медицинское обслуживание, бесплатное образование и никаких налогов!" Прохожий отвечает: "Вы ошибаетесь, я афганец." Человек идет дальше и встречает другого прохожего: "Спасибо за то, что такая красивая страна Германия! и т.д.". Человек говорит: "Я не немец, я иракец!" Вновь прибывший идет дальше, к следующему человеку, пожимает ему руку и говорит: "Спасибо за прекрасную Германию!" "Этот человек поднимает руку и говорит: "Я из Пакистана, я не из Германии!" Он, наконец, видит - идет милая дама. Спрашивает: "Вы немка?" Она говорит: "Нет, я из Индии!" Озадаченный, он спрашивает ее: "А где же немцы?" Индуска проверяет часы и отвечает: "Так они сейчас работают!"
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Cидит Мухаммед на корточках в Берлине и плюет на землю через дырку в зубах. Вдруг появляется фея и говорит:
|
||||||
|
— Я социалистическая социальная либеральная фея! Я прилетела, чтобы исполнить три желания!
|
||||||
|
— Посмотри, какая у меня дырка во рту! Я хочу, чтобы мне вылечили и вставили все зубы!
|
||||||
|
Не успел Мухаммед произнести эти слова, как тотчас вышел закон о бесплатном лечении и протезировании зубов для социальных иностранцев, и его рот засиял белоснежной голливудской улыбкой.
|
||||||
|
— Я очень скучаю по своим четырем женам и пятнадцати детишкам, а также по родителям, братьям и сестрам, родителям-братьям-сестрам моих жен! Я хочу, чтобы мы все жили на роскошной вилле, и чтобы денег всегда много было!
|
||||||
|
Не успел Мухаммед договорить, как оказался в прекрасной вилле! На столе — текст закона о воссоединении семей для социальных иностранцев, а также банковские распечатки со сведениями о поступивших пособиях. Дом полностью меблирован и оснащен электроприборами в соответствии с законом о помощи в приобретении мебели и бытовой техники для социальных иностранцев.
|
||||||
|
Счастливый Мухаммед просто не знает, чего бы ему еще попросить, ведь одно желание еще осталось. И он попросил:
|
||||||
|
— Хочу стать настоящим немцем. Не только по гражданству. Хочу быть голубоглазым блондином, и чтоб меня звали Фриц Шульц!
|
||||||
|
Не успел он закончить фразу, как все исчезло, и он обнаружил себя вновь сидящим на корточках и плюющим на землю сквозь дырку в зубах.
|
||||||
|
— Что случилось? — спросил он у феи.
|
||||||
|
— Как не стыдно, господин Шульц, клянчить у государства! Вы должны заботиться о себе сами! Идите и ищите работу!
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
А давайте больным детям на лечение брать из бюджета, а депутатам зарплату собирать на первом канале!
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Идёт Будда с учениками по дороге. Видит: яма, в ней вол, крестьянин пытается его вытянуть, но сил не хватает. Будда кивнул ученикам, они быстро помогли вытянуть животное. Идут дальше, снова яма, в ней вол, на краю сидит крестьянин и горько плачет. Будда прошёл мимо и как бы не заметил. Ученики его спрашивают:
|
||||||
|
|
||||||
|
- Учитель, почему ты не захотел помочь этому крестьянину?
|
||||||
|
- Помочь плакать?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Воздушный шар сбился с курса, и воздухоплаватель срочно опустился с ним вниз. Увидев внизу человека, он спросил:
|
||||||
|
|
||||||
|
- Извините, где я нахожусь?
|
||||||
|
- Вы находитесь на воздушном шаре, в 15м над землей. Ваши координаты - 5°28'17" N и 100°40'19" E.
|
||||||
|
- Похоже, вы математик, - вздохнул воздухоплаватель.
|
||||||
|
- Да, я математик, - согласился прохожий. - Как вы догадались?
|
||||||
|
- Ваш ответ, по-видимому, точный и полный, но для меня совершенно бесполезный. Я по-прежнему не знаю, где я нахожусь, и что мне делать. Вы мне нисколько не помогли, только напрасно отняли время.
|
||||||
|
- А вы, похоже, из управленцев, - заметил математик.
|
||||||
|
- Я действительно топ-менеджер серьезной компании, - воспрял воздухоплаватель. - Но как вы догадались? Вы видели меня по телевизору?
|
||||||
|
- Зачем? - удивился математик. - Судите сами: вы не понимаете ни где вы находитесь, ни что вам следует делать, в этом вы полагаетесь на нижестоящих. Спрашивая совета у эксперта, вы ни на секунду не задумываетесь, способны ли вы понять его ответ, и когда оказывается, что это - не так, вы возмущаетесь вместо того, чтобы переспросить. Вы находитесь ровно в том же положении, что и до моего ответа, но теперь почему-то обвиняете в этом меня. Наконец, вы находитесь выше других только благодаря дутому пузырю, и если с ним что-то случится - падение станет для вас фатальным.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Забавно, что когда Сбербанк празднует свой юбилей, то он считает свою историю с 1841 года, а когда ему задают вопросы про вклады 1991 года, то оказывается, что это совершенно другой банк.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
А давайте что-нибудь споем в поддержку артистов, попавших в сложную финансовую ситуацию?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Горит здание Сбербанка.
|
||||||
|
Звонок в пожарную охрану:
|
||||||
|
|
||||||
|
- Срочно приезжайте!!! Пожар в здании Сбербанка!!!
|
||||||
|
- Одну минуту, я переключу вас на специалиста
|
||||||
|
играет бодрая музыка, затем слышатся радостные фразы: "Если вы хотите узнать о наших новых услугах - нажмите "1". Если хотите заключить договор на монтаж противопожарного оборудования - нажмите "2". Внимание! Пожарная охрана представляет вам совершенно новый способ тушения пожаров! Хотите узнать больше? Нажмите "3". Не услышали свой вариант? Оставайтесь на линии. Приготовьте кадастровый номер вашего объекта, а также паспортные данные его владельца. Ваш звонок очень важен для нас - оставайтесь на линии.
|
||||||
|
Хотите попробовать потушить пожар самостоятельно? Воспользуйтесь услугой "Продвинутый пожарный"! Чтобы узнать, как подключить - нажмите "5""
|
||||||
|
........
|
||||||
|
- Оператор пожарной охраны Сергей, чем я могу вам помочь?
|
||||||
|
- У нас пожар! Горит три помещения!
|
||||||
|
- Скажите, как я могу к вам обращаться?
|
||||||
|
- Вы идиот? У нас здание горит! Не надо ко мне обращаться, срочно выезжайте тушить!
|
||||||
|
- Подскажите кадастровый номер здания и ФИО владельца
|
||||||
|
- Да не знаю я никакого кадастрового номера, я назвал вам адрес! Этого недостаточно, чтобы выехать на тушение пожара?!
|
||||||
|
- Оставайтесь на линии, я переведу вас на специалиста по поддержке
|
||||||
|
Играет бодрая музыка,"Если вы хотите заказать монтаж противопожарной сигнализации - произнесите: "Монтаж сигнализации", Если вы хотите подключиться к услуге "Круглосуточный пожарный расчет" - произнесите: "Подключиться". Вы не выбрали подходящий вариант. Ваш звонок будет переведен на оператора".
|
||||||
|
- Здравствуйте, меня зовут Александр, чем могу помочь?!
|
||||||
|
- У нас здание горит! Сделайте что-нибудь!
|
||||||
|
- Подскажите, как я могу к вам обращаться?
|
||||||
|
- Б...!!! С....! .... ....! Пожар!!!
|
||||||
|
- Наши специалисты рассмотрят вашу проблему. Скажите, по какому номеру мы можем с вами связаться?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
А разве первыми в военкомат вызывают не тех, у кого на машине наклеено: "Можем повторить!"?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Не спрашивай у мужчины про его доходы, у женщины про возраст, у патриота, откуда у него американский паспорт.
|
||||||
|
|
||||||
|
**********
|
642
back/internal/knocker.go
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/sha256"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed jokes.md
|
||||||
|
var jokesFile string
|
||||||
|
|
||||||
|
func GetRandomJoke() string {
|
||||||
|
// Инициализируем генератор случайных чисел
|
||||||
|
rand.Seed(time.Now().UnixNano())
|
||||||
|
|
||||||
|
jokes := strings.Split(jokesFile, "**********")
|
||||||
|
|
||||||
|
var cleanJokes []string
|
||||||
|
for _, joke := range jokes {
|
||||||
|
if trimmed := strings.TrimSpace(joke); trimmed != "" {
|
||||||
|
cleanJokes = append(cleanJokes, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cleanJokes) == 0 {
|
||||||
|
return "Шутки не найдены"
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanJokes[rand.Intn(len(cleanJokes))]
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Системная переменная для ключа шифрования
|
||||||
|
EncryptionKeyEnvVar = "GO_KNOCKER_SERVE_PASS"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config представляет конфигурацию port knocking
|
||||||
|
type Config struct {
|
||||||
|
Targets []Target `yaml:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target представляет цель для port knocking
|
||||||
|
type Target struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Ports []int `yaml:"ports"`
|
||||||
|
Protocol string `yaml:"protocol"` // "tcp" или "udp"
|
||||||
|
Delay Duration `yaml:"delay"` // задержка между пакетами
|
||||||
|
WaitConnection bool `yaml:"wait_connection"` // ждать ли установления соединения
|
||||||
|
Gateway string `yaml:"gateway"` // шлюз для отправки (опционально)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration для поддержки YAML десериализации времени
|
||||||
|
type Duration time.Duration
|
||||||
|
|
||||||
|
func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
|
||||||
|
var str string
|
||||||
|
if err := value.Decode(&str); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
duration, err := time.ParseDuration(str)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*d = Duration(duration)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PortKnocker основная структура для выполнения port knocking
|
||||||
|
type PortKnocker struct{}
|
||||||
|
|
||||||
|
// NewPortKnocker создает новый экземпляр PortKnocker
|
||||||
|
func NewPortKnocker() *PortKnocker {
|
||||||
|
return &PortKnocker{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute выполняет port knocking на основе конфигурации
|
||||||
|
func (pk *PortKnocker) Execute(configFile, keyFile string, verbose bool, globalWaitConnection bool) error {
|
||||||
|
// Читаем конфигурацию
|
||||||
|
config, err := pk.loadConfig(configFile, keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ошибка загрузки конфигурации: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pk.ExecuteWithConfig(config, verbose, globalWaitConnection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteWithConfig выполняет port knocking с готовой конфигурацией
|
||||||
|
func (pk *PortKnocker) ExecuteWithConfig(config *Config, verbose bool, globalWaitConnection bool) error {
|
||||||
|
if verbose {
|
||||||
|
fmt.Printf("Загружена конфигурация с %d целей\n", len(config.Targets))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выполняем port knocking для каждой цели
|
||||||
|
for i, target := range config.Targets {
|
||||||
|
if verbose {
|
||||||
|
fmt.Printf("Цель %d/%d: %s:%v (%s)\n", i+1, len(config.Targets), target.Host, target.Ports, target.Protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Применяем глобальный флаг если не задан локально
|
||||||
|
if globalWaitConnection && !target.WaitConnection {
|
||||||
|
target.WaitConnection = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pk.knockTarget(target, verbose); err != nil {
|
||||||
|
return fmt.Errorf("ошибка при knocking цели %s: %w", target.Host, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем задержку между целями (кроме последней)
|
||||||
|
if i < len(config.Targets)-1 && target.Delay > 0 {
|
||||||
|
if verbose {
|
||||||
|
fmt.Printf("Ожидание %v перед следующей целью...\n", time.Duration(target.Delay))
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(target.Delay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
fmt.Println("Port knocking завершен успешно")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfig загружает конфигурацию из файла с поддержкой шифрования
|
||||||
|
func (pk *PortKnocker) loadConfig(configFile, keyFile string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(configFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось прочитать файл конфигурации: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, зашифрован ли файл (начинается с "ENCRYPTED:")
|
||||||
|
if strings.HasPrefix(string(data), "ENCRYPTED:") {
|
||||||
|
fmt.Println("Обнаружен зашифрованный файл конфигурации")
|
||||||
|
|
||||||
|
// Получаем ключ шифрования
|
||||||
|
key, err := pk.getEncryptionKey(keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось получить ключ шифрования: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Расшифровываем данные
|
||||||
|
decryptedData, err := pk.decrypt(data[10:], key) // пропускаем "ENCRYPTED:"
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось расшифровать конфигурацию: %w", err)
|
||||||
|
}
|
||||||
|
data = decryptedData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим YAML
|
||||||
|
var config Config
|
||||||
|
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось разобрать YAML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfigFromString загружает конфигурацию из строки YAML
|
||||||
|
func LoadConfigFromString(yamlStr string) (*Config, error) {
|
||||||
|
// Проверяем, зашифрована ли строка (начинается с "ENCRYPTED:")
|
||||||
|
if strings.HasPrefix(yamlStr, "ENCRYPTED:") {
|
||||||
|
// Создаем временный PortKnocker для расшифровки
|
||||||
|
pk := NewPortKnocker()
|
||||||
|
|
||||||
|
// Получаем ключ шифрования
|
||||||
|
key, err := pk.getEncryptionKey("")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось получить ключ шифрования: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Расшифровываем данные
|
||||||
|
decryptedData, err := pk.decrypt([]byte(yamlStr[10:]), key) // пропускаем "ENCRYPTED:"
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось расшифровать конфигурацию: %w", err)
|
||||||
|
}
|
||||||
|
yamlStr = string(decryptedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим YAML
|
||||||
|
var config Config
|
||||||
|
if err := yaml.Unmarshal([]byte(yamlStr), &config); err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось разобрать YAML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEncryptionKey получает ключ шифрования из файла или системной переменной и хеширует его
|
||||||
|
func (pk *PortKnocker) getEncryptionKey(keyFile string) ([]byte, error) {
|
||||||
|
var rawKey []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if keyFile != "" {
|
||||||
|
// Читаем ключ из файла
|
||||||
|
rawKey, err = os.ReadFile(keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось прочитать файл ключа: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Пытаемся получить ключ из системной переменной
|
||||||
|
key := os.Getenv(EncryptionKeyEnvVar)
|
||||||
|
if key == "" {
|
||||||
|
return nil, fmt.Errorf("ключ шифрования не найден ни в файле, ни в переменной %s", EncryptionKeyEnvVar)
|
||||||
|
}
|
||||||
|
rawKey = []byte(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Хешируем ключ SHA256 чтобы получить всегда 32 байта для AES-256
|
||||||
|
hash := sha256.Sum256(rawKey)
|
||||||
|
return hash[:], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decrypt расшифровывает данные с помощью AES-GCM
|
||||||
|
func (pk *PortKnocker) decrypt(encryptedData []byte, key []byte) ([]byte, error) {
|
||||||
|
// Декодируем base64
|
||||||
|
data, err := base64.StdEncoding.DecodeString(string(encryptedData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось декодировать base64: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем AES cipher
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось создать AES cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем GCM
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось создать GCM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлекаем nonce
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(data) < nonceSize {
|
||||||
|
return nil, fmt.Errorf("данные слишком короткие")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
|
||||||
|
|
||||||
|
// Расшифровываем
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("не удалось расшифровать: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// knockTarget выполняет port knocking для одной цели
|
||||||
|
func (pk *PortKnocker) knockTarget(target Target, verbose bool) error {
|
||||||
|
// Проверяем на "шутливую" цель 1
|
||||||
|
if target.Host == "8.8.8.8" && len(target.Ports) == 1 && target.Ports[0] == 8888 {
|
||||||
|
pk.showEasterEgg()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем на "шутливую" цель 2
|
||||||
|
if target.Host == "1.1.1.1" && len(target.Ports) == 1 && target.Ports[0] == 1111 {
|
||||||
|
pk.showRandomJoke()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol := strings.ToLower(target.Protocol)
|
||||||
|
if protocol != "tcp" && protocol != "udp" {
|
||||||
|
return fmt.Errorf("неподдерживаемый протокол: %s", target.Protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вычисляем таймаут как половину интервала между пакетами
|
||||||
|
timeout := time.Duration(target.Delay) / 2
|
||||||
|
if timeout < 100*time.Millisecond {
|
||||||
|
timeout = 100 * time.Millisecond // минимальный таймаут
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, port := range target.Ports {
|
||||||
|
if verbose {
|
||||||
|
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 target.WaitConnection {
|
||||||
|
return fmt.Errorf("ошибка отправки пакета на порт %d: %w", port, err)
|
||||||
|
} else {
|
||||||
|
if verbose {
|
||||||
|
fmt.Printf(" Предупреждение: не удалось отправить пакет на порт %d: %v\n", port, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Задержка между пакетами (кроме последнего)
|
||||||
|
if i < len(target.Ports)-1 {
|
||||||
|
delay := time.Duration(target.Delay)
|
||||||
|
if delay > 0 {
|
||||||
|
if verbose {
|
||||||
|
fmt.Printf(" Ожидание %v...\n", delay)
|
||||||
|
}
|
||||||
|
time.Sleep(delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendPacket отправляет один пакет на указанный хост и порт
|
||||||
|
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))
|
||||||
|
|
||||||
|
var conn net.Conn
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Настройка локального адреса если указан шлюз
|
||||||
|
var localAddr net.Addr
|
||||||
|
if gateway != "" {
|
||||||
|
if strings.Contains(gateway, ":") {
|
||||||
|
localAddr, err = net.ResolveTCPAddr("tcp", gateway)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось разрешить адрес шлюза %s: %w", gateway, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Если указан только IP, добавляем порт 0
|
||||||
|
localAddr, err = net.ResolveTCPAddr("tcp", gateway+":0")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось разрешить адрес шлюза %s: %w", gateway, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch protocol {
|
||||||
|
case "tcp":
|
||||||
|
if localAddr != nil {
|
||||||
|
dialer := &net.Dialer{
|
||||||
|
LocalAddr: localAddr,
|
||||||
|
Timeout: timeout,
|
||||||
|
}
|
||||||
|
conn, err = dialer.Dial("tcp", address)
|
||||||
|
} else {
|
||||||
|
conn, err = net.DialTimeout("tcp", address, timeout)
|
||||||
|
}
|
||||||
|
case "udp":
|
||||||
|
if localAddr != nil {
|
||||||
|
dialer := &net.Dialer{
|
||||||
|
LocalAddr: localAddr,
|
||||||
|
Timeout: timeout,
|
||||||
|
}
|
||||||
|
conn, err = dialer.Dial("udp", address)
|
||||||
|
} else {
|
||||||
|
conn, err = net.DialTimeout("udp", address, timeout)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("неподдерживаемый протокол: %s", protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if waitConnection {
|
||||||
|
return fmt.Errorf("не удалось подключиться к %s: %w", address, err)
|
||||||
|
} else {
|
||||||
|
// Для UDP и TCP без ожидания соединения просто отправляем пакет
|
||||||
|
return pk.sendPacketWithoutConnection(host, port, protocol, localAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Отправляем пустой пакет
|
||||||
|
_, err = conn.Write([]byte{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось отправить пакет: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendPacketWithoutConnection отправляет пакет без установления соединения
|
||||||
|
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":
|
||||||
|
// Для UDP просто отправляем пакет
|
||||||
|
var conn net.Conn
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if localAddr != nil {
|
||||||
|
dialer := &net.Dialer{
|
||||||
|
LocalAddr: localAddr,
|
||||||
|
}
|
||||||
|
conn, err = dialer.Dial("udp", address)
|
||||||
|
} else {
|
||||||
|
conn, err = net.Dial("udp", address)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось создать UDP соединение к %s: %w", address, err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
_, err = conn.Write([]byte{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось отправить UDP пакет: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "tcp":
|
||||||
|
// Для TCP без ожидания соединения используем короткий таймаут
|
||||||
|
var conn net.Conn
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if localAddr != nil {
|
||||||
|
dialer := &net.Dialer{
|
||||||
|
LocalAddr: localAddr,
|
||||||
|
Timeout: 100 * time.Millisecond,
|
||||||
|
}
|
||||||
|
conn, err = dialer.Dial("tcp", address)
|
||||||
|
} else {
|
||||||
|
conn, err = net.DialTimeout("tcp", address, 100*time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Для TCP без ожидания соединения игнорируем ошибки подключения
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
_, err = conn.Write([]byte{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("не удалось отправить TCP пакет: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// showEasterEgg показывает забавный ASCII-арт
|
||||||
|
func (pk *PortKnocker) showEasterEgg() {
|
||||||
|
fmt.Println("\n🎯 🎯 🎯 EASTER EGG ACTIVATED! 🎯 🎯 🎯")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Анимированный ASCII-арт
|
||||||
|
frames := []string{
|
||||||
|
`
|
||||||
|
╭─────────────────╮
|
||||||
|
│ 🚀 PORT │
|
||||||
|
│ KNOCKER │
|
||||||
|
│ 🎯 1.0.1 │
|
||||||
|
│ │
|
||||||
|
│ 🎮 GAME ON! │
|
||||||
|
╰─────────────────╯
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
╭─────────────────╮
|
||||||
|
│ 🚀 PORT │
|
||||||
|
│ KNOCKER │
|
||||||
|
│ 🎯 1.0.1 │
|
||||||
|
│ │
|
||||||
|
│ 🎯 BULLSEYE! │
|
||||||
|
╰─────────────────╯
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
╭─────────────────╮
|
||||||
|
│ 🚀 PORT │
|
||||||
|
│ KNOCKER │
|
||||||
|
│ 🎯 1.0.1 │
|
||||||
|
│ │
|
||||||
|
│ 🎪 MAGIC! │
|
||||||
|
╰─────────────────╯
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
fmt.Print("\033[2J\033[H") // Очистка экрана
|
||||||
|
fmt.Println(frames[i%len(frames)])
|
||||||
|
time.Sleep(1500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n🎉 Поздравляем! Вы нашли пасхалку!")
|
||||||
|
fmt.Println("🎯 Попробуйте: ./port-knocker -t \"tcp:8.8.8.8:8888\"")
|
||||||
|
fmt.Println("🚀 Port Knocker - теперь с пасхалками!")
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pk *PortKnocker) showRandomJoke() {
|
||||||
|
joke := GetRandomJoke()
|
||||||
|
|
||||||
|
// ANSI цветовые коды
|
||||||
|
const (
|
||||||
|
colorReset = "\033[0m"
|
||||||
|
colorRed = "\033[31m"
|
||||||
|
colorGreen = "\033[32m"
|
||||||
|
colorYellow = "\033[33m"
|
||||||
|
colorBlue = "\033[34m"
|
||||||
|
colorPurple = "\033[35m"
|
||||||
|
colorCyan = "\033[36m"
|
||||||
|
colorWhite = "\033[37m"
|
||||||
|
colorBold = "\033[1m"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Функция для подсчета видимой длины строки (без ANSI кодов) в рунах
|
||||||
|
visibleLength := func(s string) int {
|
||||||
|
// Удаляем ANSI escape последовательности
|
||||||
|
clean := s
|
||||||
|
for strings.Contains(clean, "\033[") {
|
||||||
|
start := strings.Index(clean, "\033[")
|
||||||
|
end := strings.Index(clean[start:], "m")
|
||||||
|
if end == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
clean = clean[:start] + clean[start+end+1:]
|
||||||
|
}
|
||||||
|
// Возвращаем количество рун, а не байт
|
||||||
|
return len([]rune(clean))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для умного разбиения строки
|
||||||
|
splitLine := func(line string, maxWidth int) []string {
|
||||||
|
runes := []rune(line)
|
||||||
|
if len(runes) <= maxWidth {
|
||||||
|
return []string{line}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []string
|
||||||
|
remaining := line
|
||||||
|
|
||||||
|
for len([]rune(remaining)) > maxWidth {
|
||||||
|
// Ищем позицию для разрыва в пределах maxWidth
|
||||||
|
breakPos := maxWidth
|
||||||
|
remainingRunes := []rune(remaining)
|
||||||
|
|
||||||
|
for i := maxWidth; i >= 0; i-- {
|
||||||
|
if i < len(remainingRunes) {
|
||||||
|
char := remainingRunes[i]
|
||||||
|
// Разрываем на пробеле, знаке пунктуации или в конце строки
|
||||||
|
if char == ' ' || char == ',' || char == '.' || char == '!' ||
|
||||||
|
char == '?' || char == ':' || char == ';' || char == '-' {
|
||||||
|
breakPos = i + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если не нашли подходящего места, разрываем по maxWidth
|
||||||
|
if breakPos == maxWidth {
|
||||||
|
breakPos = maxWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем строку из рун
|
||||||
|
breakString := string(remainingRunes[:breakPos])
|
||||||
|
result = append(result, strings.TrimSpace(breakString))
|
||||||
|
remaining = strings.TrimSpace(string(remainingRunes[breakPos:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len([]rune(remaining)) > 0 {
|
||||||
|
result = append(result, remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разбиваем исходную шутку на строки
|
||||||
|
originalLines := strings.Split(joke, "\n")
|
||||||
|
|
||||||
|
// Обрабатываем каждую строку и разбиваем длинные
|
||||||
|
var processedLines []string
|
||||||
|
for _, line := range originalLines {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
splitLines := splitLine(line, 80)
|
||||||
|
processedLines = append(processedLines, splitLines...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находим максимальную длину строки для рамки (в рунах)
|
||||||
|
maxLength := 0
|
||||||
|
for _, line := range processedLines {
|
||||||
|
lineLength := len([]rune(line))
|
||||||
|
if lineLength > maxLength {
|
||||||
|
maxLength = lineLength
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Убеждаемся, что maxLength не меньше минимальной ширины для заголовков
|
||||||
|
minWidth := 60 // Минимальная ширина для заголовков
|
||||||
|
if maxLength < minWidth {
|
||||||
|
maxLength = minWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("%s%s╭%s", colorPurple, colorBold, colorReset)
|
||||||
|
fmt.Printf("%s%s", colorYellow, strings.Repeat("─", maxLength+2))
|
||||||
|
fmt.Printf("%s%s╮%s\n", colorPurple, colorBold, colorReset)
|
||||||
|
|
||||||
|
headerText := " Зацени Анектотец! 🤣 "
|
||||||
|
fmt.Printf("%s%s│%s", colorPurple, colorBold, colorReset)
|
||||||
|
fmt.Printf("%s%s%s%s", colorCyan, colorBold, headerText, colorReset)
|
||||||
|
fmt.Printf("%s%s", colorYellow, strings.Repeat(" ", 1+maxLength-visibleLength(headerText)))
|
||||||
|
fmt.Printf("%s%s│%s\n", colorPurple, colorBold, colorReset)
|
||||||
|
|
||||||
|
fmt.Printf("%s%s├%s", colorPurple, colorBold, colorReset)
|
||||||
|
fmt.Printf("%s%s", colorYellow, strings.Repeat("─", maxLength+2))
|
||||||
|
fmt.Printf("%s%s┤%s\n", colorPurple, colorBold, colorReset)
|
||||||
|
|
||||||
|
// Выводим обработанные строки шутки
|
||||||
|
for _, line := range processedLines {
|
||||||
|
fmt.Printf("%s%s│%s", colorPurple, colorBold, colorReset)
|
||||||
|
fmt.Printf("%s%s%s", colorWhite, line, colorReset)
|
||||||
|
fmt.Printf("%s%s", colorYellow, strings.Repeat(" ", 2+maxLength-len([]rune(line))))
|
||||||
|
fmt.Printf("%s%s│%s\n", colorPurple, colorBold, colorReset)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s%s├%s", colorPurple, colorBold, colorReset)
|
||||||
|
fmt.Printf("%s%s", colorYellow, strings.Repeat("─", maxLength+2))
|
||||||
|
fmt.Printf("%s%s┤%s\n", colorPurple, colorBold, colorReset)
|
||||||
|
|
||||||
|
// Вычисляем правильную ширину для нижних строк
|
||||||
|
cmdText := "Попробуйте: ./port-knocker -t \"tcp:1.1.1.1:1111\""
|
||||||
|
titleText := "🚀 Port Knocker - теперь с шутками! 🤣"
|
||||||
|
|
||||||
|
fmt.Printf("%s%s│%s", colorPurple, colorBold, colorReset)
|
||||||
|
fmt.Printf("%s%s%s%s", colorGreen, colorBold, cmdText, colorReset)
|
||||||
|
fmt.Printf("%s%s", colorYellow, strings.Repeat(" ", 2+maxLength-visibleLength(cmdText)))
|
||||||
|
fmt.Printf("%s%s│%s\n", colorPurple, colorBold, colorReset)
|
||||||
|
|
||||||
|
fmt.Printf("%s%s│%s", colorPurple, colorBold, colorReset)
|
||||||
|
fmt.Printf("%s%s%s%s", colorBlue, colorBold, titleText, colorReset)
|
||||||
|
fmt.Printf("%s%s", colorYellow, strings.Repeat(" ", maxLength-visibleLength(titleText)))
|
||||||
|
fmt.Printf("%s%s│%s\n", colorPurple, colorBold, colorReset)
|
||||||
|
|
||||||
|
fmt.Printf("%s%s╰%s", colorPurple, colorBold, colorReset)
|
||||||
|
fmt.Printf("%s%s", colorYellow, strings.Repeat("─", maxLength+2))
|
||||||
|
fmt.Printf("%s%s╯%s\n", colorPurple, colorBold, colorReset)
|
||||||
|
fmt.Println()
|
||||||
|
}
|
568
back/jokes.md
Normal file
@@ -0,0 +1,568 @@
|
|||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
На собеседовании в школе для особо одарённых детей шестилетнего Вовочку попросили рассказать, чем автобус отличается от троллейбуса.
|
||||||
|
Вовочка ничего скрывать от тёти не стал и честно ей сообщил, что автобус работает на двигателе внутреннего сгорания, а троллейбус - на электродвигателе переменного тока.
|
||||||
|
Оказалось - ничего подобного!
|
||||||
|
Просто троллейбус с рогами, а автобус - без.
|
||||||
|
И нечего тут морочить тёте голову!
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Гламурная москвичка приезжает погостить в деревню к бабушке.
|
||||||
|
|
||||||
|
- Бабуль, куда у вас тут ночью сходить можно?
|
||||||
|
- В ведро.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Только перечитывая в 40 лет книгу "Д'Артаньян и три мушкетёра" ты наконец-то начинаешь понимать, что единственный положительный герой в этой книге - кардинал Ришелье...
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Вместо того, чтобы у себя в Алжире, Марокко и Тунисе строить жизнь, как во Франции, люди приезжают во Францию, чтобы там создать себе такую жизнь, как в Алжире, Марокко и Тунисе. Это странно. Ещё более странно заставлять при этом и французов жить, как в Алжире, Марокко и Тунисе.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Твоя религия ничего МНЕ не запрещает. Она запрещает ТЕБЕ. Уясни это.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Антон Силуанов предложил россиянам не думать о ключевой ставке ЦБ.
|
||||||
|
Также россиянам не стоит думать о:
|
||||||
|
|
||||||
|
- процентах по кредитам;
|
||||||
|
- курсе рубля;
|
||||||
|
- тарифах ЖКХ;
|
||||||
|
- ценах в магазинах.
|
||||||
|
В то же время россиянам стоит думать о:
|
||||||
|
- госдолге США;
|
||||||
|
- крахе доллара;
|
||||||
|
- величии державы;
|
||||||
|
- необходимости потерпеть.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Василий Иванович с Петькой сидят, скучают. Василий Иванович:
|
||||||
|
|
||||||
|
- Петька, сгоняй на хутор к старику-самогонщику, сообрази чего-нибудь!
|
||||||
|
- Это можно.
|
||||||
|
Через час Петька возвращается. Василий Иванович:
|
||||||
|
- Ну как?
|
||||||
|
- Да нет у него ни фига.
|
||||||
|
- Эх, молодежь, всему вас учить надо. Пойдем вместе.
|
||||||
|
Приходят к деду. Василий Иванович:
|
||||||
|
- Здорово, отец!
|
||||||
|
- Здорово, сынки!
|
||||||
|
- Мы вот к тебе от имени Советской власти. Вот я гляжу, хата у тебя старая.
|
||||||
|
- Ох, какая старая!
|
||||||
|
- Петька, запиши: новую хату ему от Советской власти! И жена у тебя вроде старая...
|
||||||
|
- Ой, какая старая!!
|
||||||
|
- Петька, запиши: новую жену ему от Советской власти!
|
||||||
|
- Сынки! Родненькие! Да сколько ж я вас ждал!
|
||||||
|
Ну, сели за стол, выпили, с собой взяли. Уже в дверях Василий Иванович как бы невзначай:
|
||||||
|
- Слышь, дед! А может у тебя и /хрен/ старый?
|
||||||
|
- Уж каккой старый!!!
|
||||||
|
- Петька, запиши: /хрен/ ему от Советской власти!
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Таможенник поднимается на корабль для досмотра.
|
||||||
|
|
||||||
|
- Наркотики есть?
|
||||||
|
Хозяин корабля отвечает:
|
||||||
|
- Есть. Вот, пожалуйста (достает чемодан). Вот героин, вот кокаин. Все аккуратно упаковано,вот шприц готовый.
|
||||||
|
Таможенник, вытаращив глаза:
|
||||||
|
- А может, и оружие есть?
|
||||||
|
Хозяин (достает другой чемодан):
|
||||||
|
- Вот Макаров, вот Калашник ,вот патроны к ним. Все как надо.
|
||||||
|
Таможенник с усмешкой:
|
||||||
|
- Наверное, и валюта есть?
|
||||||
|
Хозяин достает третий чемодан:
|
||||||
|
- Вот миллион долларов, пожалуйста.
|
||||||
|
Таможенник, ничего не понимая:
|
||||||
|
- И это все ваше?
|
||||||
|
Хозяин:
|
||||||
|
- Нет, это ваше. Мое в трюме.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Летит самолет. Пилот по громкой связи:
|
||||||
|
|
||||||
|
- Уважаемые дамы и господа, вас приветствует командир корабля. Прослушайте информацию о нашем полете. Наш полет проходит на высоте 10 тысяч метров со скоростью 900 километров в час, температура за бортом...БЛИН...А-А-А-А!.. ЭТО ЧТО ТАКОЕ?.. НУ, КРЫНДЕЦ...
|
||||||
|
В салоне гробовая тишина.
|
||||||
|
Через минуту опять по радио:
|
||||||
|
- Прошу извинения у уважаемых пассажиров. Просто это наша стюардесса опрокинула на меня горячий кофе. Видели ли бы вы теперь мои белые брюки спереди...
|
||||||
|
Мужик в первом ряду:
|
||||||
|
- Твои брюки, это все херня! Видел бы ты мои брюки сзади...
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Новый учитель, придя в класс, обнаружил, что одного мальчика дразнят Мойше-дурачок. На перемене он спросил ребят, почему они его так обзывают.
|
||||||
|
|
||||||
|
- Да он и вправду дурачок, господин учитель. Если дать ему большую монету в пять шекелей и маленькую в десять, он выберет пять, потому что думает, что она больше. Вот, смотрите...
|
||||||
|
Парень достает две монеты и предлагает Мойше выбрать. Тот, как всегда,
|
||||||
|
выбирает пять. Учитель с удивлением спращивает:
|
||||||
|
- Почему же ты выбрал монету в пять шекелей, а не в десять?
|
||||||
|
- Посмотрите, она же больше, господин учитель!
|
||||||
|
После уроков учитель подошел к Мойше.
|
||||||
|
- Неужели ты не понимаешь, что пять шекелей больше только по размерам,
|
||||||
|
но на десять шекелей можно купить больше?
|
||||||
|
- Конечно понимаю, господин учитель.
|
||||||
|
- Так почему же ты выбираешь пять?
|
||||||
|
- Потому что, если я выберу десять, они перестанут давать мне деньги!
|
||||||
|
|
||||||
|
Четвёртая десятка
|
||||||
|
**********
|
||||||
|
|
||||||
|
Как разные народы переносят низкие температуры:
|
||||||
|
+10 C: Американцев трясет. Русские сажают огурцы в огородах.
|
||||||
|
+1.6 C: У итальянцев не заводятся машины. Русские ездят с опущенными стеклами.
|
||||||
|
0 C: В Америке замерзает вода. В России вода загустевает.
|
||||||
|
|
||||||
|
- 17.9 C: В Нью-Йорке домовладельцы включают отопление. Русские последний раз в сезоне выезжают на пикники.
|
||||||
|
- 42 C: В Европе не функционирует транспорт. Русские едят мороженое на улице.
|
||||||
|
- 73 C: Финский спецназ эвакуирует Санта-Клауса из Лапландии. Русские надевают ушанки.
|
||||||
|
- 114 C: Замерзает этиловый спирт. У русских плохое настроение.
|
||||||
|
- 273 C: Абсолютный ноль, остананавливается атомарное движение. Русские ругаются: "Холодно, мля!"
|
||||||
|
- 295 C: У католиков в аду замерзают черти. Российская сборная по футболу становится чемпионом мира.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
На дискотеке в Германии русский в майке с надписью: "У турков три проблемы".
|
||||||
|
К нему тут же подходит турок и спрашивает:
|
||||||
|
|
||||||
|
- Ты чего? Проблем ищешь? Ты наехать хочешь?
|
||||||
|
- Это ваша первая проблема. Агрессивность. Вы всегда пытаетесь создавать проблемы на пустом месте.
|
||||||
|
Когда дискотека заканчивается, то русского уже подкарауливает группа
|
||||||
|
турков.
|
||||||
|
- Сейчас ты ответишь за свои слова! - говорят они.
|
||||||
|
- Это ваша вторая проблема. Вы не можете решать свои проблемы сами и сразу собираете своих по любому поводу.
|
||||||
|
- Да как ты смеешь с нами так говорить?!! - турки повыхватывали ножи...
|
||||||
|
- Это ваша третья проблема, - продолжает русский. - Вы всегда приходите с ножами на перестрелку.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
ДЕЛОВОЕ ПРЕДЛОЖЕНИЕ
|
||||||
|
(пер. с англ, автор мне неизвестен)
|
||||||
|
Джонни очень хотел одну девушку в своем офисе, но она принадлежала другому... Как-то раз ему стало так невмоготу, что он подошел к ней и сказал: "Я
|
||||||
|
дам тебе 1000 долларов, если ты мне отдашься", но девушка ответила "НЕТ".
|
||||||
|
Джонни сказал: "Да я быстро - я брошу деньги на пол, ты нагнешься подобрать, а как поднимешь - я уже закончу". Девушка задумалась на секунду, и ответила, что должна проконсультироваться с бойфрендом.
|
||||||
|
|
||||||
|
Она позвонила и рассказала тому все. Бойфренд ответил: "Проси 2000, и поднимай деньги очень быстро, так чтоб он даже не успел спустить штаны".
|
||||||
|
Девушка согласилась, и дала свое согласие Джонни.
|
||||||
|
|
||||||
|
Прошло полчаса, бойфренд ждет, а девушка все не звонит... Наконец спустя 45 минут бойфренд позвонил сам и спросил, что случилось. Девушка ответила: "Этот подонок расплатился монетами."
|
||||||
|
|
||||||
|
МОРАЛЬ: Всегда рассматривайте деловое предложение досконально, до того, как вы его примете и вас поимеют!
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Начало учебного года в американской школе. Классная руководительница знакомит класс:
|
||||||
|
|
||||||
|
- Дети, у нас новенький – Шакиро Сузуки из Японии, знакомьтесь. А сейчас начинаем урок и посмотрим, как хорошо вы знаете американскую историю.
|
||||||
|
Кто сказал "Свобода или смерть"?
|
||||||
|
В классе мертвая тишина. Сузуки вскидывает руку:
|
||||||
|
- Патрик Генри, 1775 год, Филадельфия.
|
||||||
|
- Очень хорошо. А чьи слова: "Государство – это народ, и как таковое никогда не должно умереть"?
|
||||||
|
Опять рука Сузуки:
|
||||||
|
- Абрахам Линкольн, 1863 год, Вашингтон.
|
||||||
|
Учительница строго смотрит на класс:
|
||||||
|
- Стыдно, дети! Сузуки – японец, а знает американскую историю лучше всех!
|
||||||
|
В этот момент тихий голос с задней парты:
|
||||||
|
- Задолбали сраные япошки!
|
||||||
|
Учительница резко оборачивается:
|
||||||
|
- Кто сказал???!!!
|
||||||
|
Сузуки вскакивает и оттарабанивает:
|
||||||
|
- Генерал МакАртур, остров Гвадалканал, 1942 год.
|
||||||
|
Возмущенный вопль:
|
||||||
|
- Сузуки – дерьмо!!!
|
||||||
|
И ни секунды задержки:
|
||||||
|
- Валентино Росси на мотогонках ГранПри-Бразилия в Рио де Жанейро, 2002
|
||||||
|
год! –выпаливает японец!
|
||||||
|
Класс в истерике, училка в обмороке, распахивается дверь и появляется
|
||||||
|
разъяренный директор школы:
|
||||||
|
- Вашу мать! Что здесь за бардак???!!!
|
||||||
|
Не успевший сесть Сузуки:
|
||||||
|
- Президент Ельцин, заседание парламента России, 1993 год!
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Муж:
|
||||||
|
|
||||||
|
- Какого тёща приезжает?
|
||||||
|
Жена:
|
||||||
|
- Числа или хрена?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Лозунг "Задушим коррупцию" был признан экстремистским как призывающий к
|
||||||
|
насильственному свержению существующего строя.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Урок "Основы православной культуры". Учительница:
|
||||||
|
|
||||||
|
- И помните, дети! Те, кто будет учиться на "4" и "5", попадут в рай. А
|
||||||
|
те, кто будет учиться на "2" и "3", - в ад!
|
||||||
|
Вовочка с задней парты:
|
||||||
|
- Мариванна, а что, закончить школу живым нельзя?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Штаб Ку Клукс Клана:
|
||||||
|
|
||||||
|
- Скажите, как вступить в вашу организацию?
|
||||||
|
- Это просто. Нужно замочить 6 негров и одного кота.
|
||||||
|
- А кота за что?
|
||||||
|
- Поздравляю, вы приняты
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Увидев на холодильнике всего два магнитика - из Магадана и Воркуты, воры покормили кота и вымыли посуду.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Боевик ИГИЛ остановил автомобиль христианской пары.
|
||||||
|
Боевик ИГИЛ: «Ты мусульманин?»
|
||||||
|
Христианин: «Да, я мусульманин».
|
||||||
|
Боевик ИГИЛ: «Если ты мусульманин, перескажи суру из Корана».
|
||||||
|
Христианин рассказал стихотворение из Библии.
|
||||||
|
Боевик ИГИЛ: «Хорошо, можешь ехать».
|
||||||
|
Через несколько минут жена, едва переведя дух, говорит мужу: «Не могу поверить, как ты пошел на такой риск. Почему ты сказал, что мы мусульмане? Если бы он узнал, что ты врёшь, он убил бы нас обоих!»
|
||||||
|
«Зря волновалась. Если бы они знали Коран, они бы никогда не убивали людей!» – ответил ей муж.
|
||||||
|
|
||||||
|
Третья десятка
|
||||||
|
**********
|
||||||
|
|
||||||
|
Сборная России взяла 4 золота на международной олимпиаде по физике в Цюрихе. Деньги и белые BMW никто не предложил. Даже не заметили.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Когда в стране коррупции нет — микролитражки мчат по хайвеям.
|
||||||
|
Когда коррупция — Бентли тащатся по бездорожью.
|
||||||
|
Всё просто, брат.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Интересно, а если провести обыск у всего руководства ФСБ - можно будет обратно понизить пенсионный возраст?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Центр организации дорожного движения Москвы пришел к выводу, что личный автомобиль гражданину не нужен. 93% времени он стоит на приколе, а лишь 7% используется, заявил руководитель Департамента транспорта Максим Ликсутов.
|
||||||
|
Остроумные люди посоветовали Максиму Ликсутову отрезать пенис, которым он пользуется меньше 10 минут в день.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
По поводу слов Кадырова, что его достало, что во всем обвиняют кавказцев... Знакомый татарин сказал: До тех пор пока ты ЧЕЛОВЕК, никого в России твоя национальность особо не интересует. Как только стал СКОТИНОЙ всем сразу интересно, чья это скотина гадит?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Зачем пересаживать чиновников на отечественные автомобили, если они имеют право на бесплатный проезд в общественном транспорте?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Почему те, кто хочет носить хиджаб не живут там, где его ношение приветствуется?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
В кафе заходит человек с собакой и заключает с посетителями пари,что его пес сейчас будет разговаривать. Но собака молчит. Человек оплачивает пари и уходит под общий хохот.
|
||||||
|
|
||||||
|
- Из-за тебя я проиграл уйму денег! - говорит хозяин собаке. - Почему ты не заговорил?
|
||||||
|
- Чудак! - отвечает пес. - Ты только представь, сколько денег мы загребем завтра.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Выходит утром гаишник на дорогу, голова после вчерашнего раскалывается.
|
||||||
|
Смотрит - джип несется. Ну он остановил его с целью сбора средств на опохмел. Смотрит, а там бомж сидит. Документы проверил - правда, бомжа машина. Ну мент его спрашивает:
|
||||||
|
|
||||||
|
- Ты же бомж. Ты где такую крутую тачку взял?
|
||||||
|
- А мне пьяные новые русские предложили, если я их рассмешу - джип мой. Ну я их и рассмешил.
|
||||||
|
- А как?
|
||||||
|
- Да я одному лысому на голову нагадил, у него сразу волосы выросли, вот умора была.
|
||||||
|
Мент шапку снимает, там лысина. Он и говорит:
|
||||||
|
- А ты мне так можешь?
|
||||||
|
- Могу.
|
||||||
|
Бомж гадит менту на лысину, a из кустов раздается хохот и крик:
|
||||||
|
- Не, ну ваще, да я ему еще и хату подарю.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Вовочка приходит в аптеку:
|
||||||
|
|
||||||
|
- Дайте мне упаковку презервативов!
|
||||||
|
- Во-первых, это не для детей, - отвечает аптекарь, - а во-вторых, пусть придет папа и возьмет нужный размер.
|
||||||
|
- Во-первых, это не для детей, а от детей, а во-вторых, это не для папы, а мама едет на курорт, и какие там размеры будут, она еще не знает...
|
||||||
|
|
||||||
|
Кенийский бегун Абель Мутай был всего в нескольких футах от финиша, но перепутал с вывесками и остановился, думая, что завершил гонку. Испанский бегун, Иван Фернандес, стоял за ним и, понимая, что происходит, начал кричать на кенийца, чтобы он продолжил бег. Мутай не знал испанского и не понял. Понимая, что происходит, Фернандес толкнул Мутая к победе. Журналист спросил Ивана: «Зачем ты это сделал?» Иван ответил: «Моя мечта заключается в том, чтобы когда-нибудь у нас была такая общественная жизнь, где мы толкаемся и помогаем друг другу побеждать». Журналистка настаивала: «Но почему ты дал победить Кении?» Иван ответил:«Я не дал ему победить, он собирался победить. Гонка была его». Журналист настаивал, и снова спросил: «А ведь можно было победить!» Иван посмотрел на него и ответил: «А в чем заслуга моей победы? Какая честь будет в этой медали? Что бы моя мама об этом подумала? Ценности передаются из поколения в поколение. Каким ценностям мы учим наших детей?»
|
||||||
|
Вторая десятка
|
||||||
|
**********
|
||||||
|
|
||||||
|
Попали в Ад американец, индус и русский. Встретил их Черт и говорит:
|
||||||
|
|
||||||
|
- Всем, кто сюда попадает, даю шанс перейти в Рай.
|
||||||
|
И достает здоровенный кнут (побольше, чем у Харрисона Форда в "Последнем
|
||||||
|
крестовом походе"):
|
||||||
|
- Кто выдержит три удара не закричав - отпускаю! Можете защищаться, чем хотите.
|
||||||
|
Первым вышел американец.
|
||||||
|
- Чем хочешь защищаться?
|
||||||
|
Американец взял здоровый гранитный камень:
|
||||||
|
- Я готов!
|
||||||
|
Черт размахнулся в первый раз и... камень вдребезги. Второй раз - и американец заорал как бешенный...
|
||||||
|
- Следующий, - говорит Черт.
|
||||||
|
Выходит индус.
|
||||||
|
- Чем будешь защищаться?
|
||||||
|
- Ничем! - отвечает индус, - Я 80 лет занимался йогой, и в медитации тело не чувствует боли!
|
||||||
|
- Ладно.
|
||||||
|
Первый удар. Индус: - Ошшш...
|
||||||
|
Второй удар. Индус: - Ошшш...
|
||||||
|
Третий удар. Индус: - Ошшш...
|
||||||
|
- Ух е# твою... Еще никто не выдерживал трех ударов. - говорит Черт. - Ну
|
||||||
|
что ж, ты свободен, можешь спокойно идти в Рай.
|
||||||
|
- Нет, - говорит индус, - хочу остаться и посмотреть. Во всех анекдотах русские выигрывают. Хочу увидеть, как у него на этот раз получится.
|
||||||
|
- Ладно, останься. Ну, чем думаешь защищаться? - обращается Черт к русскому.
|
||||||
|
- Чем защищаться - индусом, конечно...
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Отвечать надо быстро, не раздумывая и не тратя понапрасну время.
|
||||||
|
А главное - не мошенничать!
|
||||||
|
|
||||||
|
1. Вы участвуете в соревнованиях и обогнали бегуна, занимающего вторую
|
||||||
|
позицию. Какую позицию вы теперь занимаете?
|
||||||
|
Ответ: Если вы ответили, что вы теперь первый - то вы абсолютно не
|
||||||
|
правы.
|
||||||
|
Вы обогнали второго бегуна и заняли его место, так что вы теперь на
|
||||||
|
второй позиции.
|
||||||
|
Попробуйте не ошибиться во втором вопросе.
|
||||||
|
2. Вы обогнали последнего бегуна, на какой позиции вы теперь находитесь?
|
||||||
|
Ответ: Если вы ответили на предпоследнем - вы опять абсолютно не правы.
|
||||||
|
Подумайте. Как можно обогнать бегуна, идущего последним? Если вы бежите
|
||||||
|
за ним, значит он не последний. Ответ - это невозможно. Получается, что
|
||||||
|
использование мозга ваша не самая сильная сторона.
|
||||||
|
Как бы то ни было - вот еще один вопрос. Ничего не пишите и не
|
||||||
|
используйте калькулятор, и помните - вы должны отвечать быстро.
|
||||||
|
Возьмите 1000. Прибавьте 40. Прибавьте еще тысячу. Прибавьте 30.
|
||||||
|
Еще 1000.
|
||||||
|
Плюс 20. Плюс 1000. И плюс 10. Что получилось?
|
||||||
|
Ответ 5000? Опять неверно. Правильный ответ 4100. Попробуйте пересчитать
|
||||||
|
на калькуляторе.
|
||||||
|
Сегодня точно не ваш день. Но, может быть, получится с последним вопросом.
|
||||||
|
У отца Мэри есть пять дочерей: 1. Чача 2. Чече 3. Чичи 4 Чочо.
|
||||||
|
Вопрос: Как зовут пятую дочь? Думайте быстро. Ответ чуть ниже.
|
||||||
|
Ответ: Чучу? НЕТ! Конечно, ее зовут Мэри. Прочтите еще раз вопрос.
|
||||||
|
ВЫВОД: Вы самое слабое звено - прощайте.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Мужик просыпается с утра с жуткого бодуна, открывает глаза, голова болит, оглядывается по сторонам: фуууу, дома... встает с кровати, ощупывает себя - е-мое, в пижаме... в жизни пижаму не одевал. Смотрит - на туалетном столике стакан воды, таблетка аспирина и записка от жены:
|
||||||
|
"Милый, завтрак на столе, все прибрала, твоя навеки - жена". Мужик в совершенном непонимании, выпивает таблетку и идет в ванную... по пути обнаруживает, что квартира не то что чистая, просто вылизана до блеска, сын сидит у себя в комнате, делает уроки...
|
||||||
|
|
||||||
|
- Сынок, а что вчера было?
|
||||||
|
- Ты пришел пьяный, как обычно под утро. Облевал всю прихожую, нагадил мимо унитаза, побил в кухне всю посуду, поставил матери фингал под глазом.
|
||||||
|
- Ну и, что случилось с мамой, с квартирой???
|
||||||
|
- Ааа, ты про это, просто когда тебя мама стала укладывать спать и начала стягивать с тебя штаны, ты заорал "уйди, сука - Я ЖЕНАТЫЙ!!!"
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Штатский Джонс был назначен в армейский учебный центр, где он должен был просвещать рекрутов по поводу различных правительственных обязательств перед ними, особенно о Страховании Жизни Военослужащих (СЖВ). Вскоре после этого лейтенант центра заметил, что Джонс имеет почти 100%-ю продажу страховок СЖВ, чего раньше никогда не бывало. Лейтенант сел в конце заполненной рекрутами комнаты и стал слушать торговую подачу Джонса. Джонс объяснил новым рекрутам основы СЖВ, а затем сказал:
|
||||||
|
"Если у вас есть СЖВ и вы пошли в бой и погибли, - правительство обязано выплатить вашим наследникам 200 000$. Если у вас нет СЖВ и вы пошли в бой и погибли, - правительство обязано выплатить вашим наследникам максимум всего лишь 6000$". "А теперь", сказал он в заключение, "как вы думаете, кого они пошлют в бой первыми?"
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
- Ватсон, а что это вы курите? Дайте угадаю - табак "Королева Вирджиния"
|
||||||
|
с листочками вишни, из юбилейного выпуска в бархатной упаковке?
|
||||||
|
- Поразительно, Холмс! Как это вы угадали?
|
||||||
|
- Ей-богу, Ватсон! Ну не миссис Хадсон же свистнула из моей комнаты
|
||||||
|
последнюю пачку!
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
У последней остановки метро ждет автобуса инженер, который допоздна
|
||||||
|
делал халтуру на работе. Полдвенадцатого ночи. Автобуса нет. Он весь
|
||||||
|
задубел... И тут возле него останавливается шикарный Лексус, опускается
|
||||||
|
окно и девушка типа “порномодель” говорит: "Садитесь, я вас подвезу". Он
|
||||||
|
отнекивается, мол денег нету...Она: "Да какие деньги! Вы ж на бирюлевский
|
||||||
|
автобус тут стоите... А как они ходят?! Садитесь, я так вас подвезу, а то
|
||||||
|
замерзнете..."
|
||||||
|
Он сел назад. Поехали. Тепло. Класс. И тут она спрашивает:
|
||||||
|
|
||||||
|
- Ничего, если мы за подружкой моей заедем? Я с ней раньше
|
||||||
|
договаривалась. Но это по пути... Пара минут...
|
||||||
|
Он говорит:
|
||||||
|
- Конечно... Хозяин-барин. Какие вопросы...
|
||||||
|
Заехали. Выходит девушка такого же калибра, как и первая. Плюхается на
|
||||||
|
сиденье и говорит:
|
||||||
|
- Мань, я похавать не успела. Давай причалим к магазинчику хавки купим...
|
||||||
|
Причалили... Та зашла... Выходит. У нее 2 бутылки французского шампанского
|
||||||
|
по штуке баксов, сувенирное (на полкило) ведерочко черной икры,
|
||||||
|
французские батоны, еще что-то в фирменных коробочках...
|
||||||
|
Едут... Высаживают мужика... И тут та, что со жратвой говорит:
|
||||||
|
- Мань, а что мы тут в машине крошить будем?
|
||||||
|
А та, что за рулем - мужику:
|
||||||
|
- Вы не против, если мы на пять минут к вам зайдем, перекусим и дальше
|
||||||
|
поедем?
|
||||||
|
Он извиняется, что мол, холостяцкий беспорядок, они: “Ничего... Мы
|
||||||
|
ненадолго...”
|
||||||
|
Поднялись к нему. Выпили эти две бутылки. Закусили... И /делали взрослые дела/ втроем до утра.
|
||||||
|
|
||||||
|
А через какое-то время эти телки прохаживаются в Доме кино по какой-то
|
||||||
|
тусовке. И одна говорит:
|
||||||
|
|
||||||
|
- Как все это меня достало! Эти престарелые плейбои, этот Михалков со
|
||||||
|
своими проститутками, этот Гусман старый дедун, эти все заслуженные
|
||||||
|
П***** России... Блин, смотреть уже на них не могу.
|
||||||
|
А вторая:
|
||||||
|
- Слушай, давай плюнем на это все и поедем к Коле в Бирюлево!
|
||||||
|
Первая:
|
||||||
|
- Да-а... К Коле в Бирюлево... Думаешь, он нас вспомнит?
|
||||||
|
|
||||||
|
ПАМЯТНИК ЛАБРАДОРУ МОНТИ *** В городе Квиснсленде (Австралия) жил лабрадор по кличке Монти. Хозяин был глубоко пожилым человеком. Он всегда брал Монти в походы по местным магазинам и обучил его носить в зубах свою корзину с продуктами. Однажды владелец Монти был не в силах пойти по магазинам. Он послал Монти со списком покупок и деньгами в корзине. Монти обошел все магазины, в которые он заходил вместе с хозяином. Продавцы читали записку и клали в корзинку необходимое. С тех пор Монти каждый день бегал по магазинам с корзинкой в зубах. Монти получил такую известность, что когда случилось неизбежное и он умер, местная община решила возвести ему памятник в виде бронзовой статуи с корзинкой, полной продуктов. Теперь, бронзовый Монти в натуральную величину сидит при входе в торговый центр, куда он бегал за продуктами для своего хозяина. Памятник установили 15 июня 1996 года.
|
||||||
|
**********
|
||||||
|
|
||||||
|
Как попасть в рай (притча)
|
||||||
|
По длинной, дикой, утомительной дороге шел человек с собакой.
|
||||||
|
Шел он себе шел, устал, собака тоже устала. Вдруг перед ним - оазис!
|
||||||
|
Прекрасные ворота, за оградой - музыка, цветы, журчание ручья,
|
||||||
|
словом, отдых.
|
||||||
|
|
||||||
|
- Что это такое? - спросил путешественник у привратника.
|
||||||
|
- Это рай, ты уже умер, и теперь можешь войти и отдохнуть
|
||||||
|
по-настоящему.
|
||||||
|
- А есть там вода?
|
||||||
|
- Сколько угодно: чистые фонтаны, прохладные бассейны...
|
||||||
|
- А поесть дадут?
|
||||||
|
- Все, что захочешь.
|
||||||
|
- Но со мной собака.
|
||||||
|
- Сожалею, сэр, с собаками нельзя. Ее придется оставить здесь.
|
||||||
|
И путешественник пошел мимо.. Через некоторое время дорога привела его
|
||||||
|
на ферму. У ворот тоже сидел привратник.
|
||||||
|
- Я хочу пить, - попросил путешественник.
|
||||||
|
- Заходи, во дворе есть колодец.
|
||||||
|
- А моя собака?
|
||||||
|
- Возле колодца увидишь поилку.
|
||||||
|
- А поесть?
|
||||||
|
- Могу угостить тебя ужином.
|
||||||
|
- А собаке?
|
||||||
|
- Найдется косточка.
|
||||||
|
- А что это за место?
|
||||||
|
- Это рай.
|
||||||
|
- Как так? Привратник у дворца неподалеку сказал мне, что рай - там.
|
||||||
|
- Врет он все. Там ад.
|
||||||
|
- Как же вы, в раю, это терпите?
|
||||||
|
- Это нам очень полезно. До рая доходят только те, кто не бросает
|
||||||
|
своих друзей.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Мужик едет на встречу, опаздывает, нервничает, не может найти место
|
||||||
|
припарковаться. Поднимает лицо к небу и говорит:
|
||||||
|
— Господи, помоги мне найти место для парковки. Я тогда брошу пить и
|
||||||
|
буду каждое воскресенье ходить в церковь!
|
||||||
|
Вдруг чудесным образом появляется свободное местечко. Мужик снова
|
||||||
|
обращается к небу:
|
||||||
|
— А, всё, не надо. Нашёл!
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Журналисты спрашивают у фермера:
|
||||||
|
|
||||||
|
- Скажите, как у вас прошел год.
|
||||||
|
- Не поверите, замечательно. Урожай зерна хороший - без хлеба не
|
||||||
|
останусь, картошка удалась - опять таки буду не голодный, а еще свинья
|
||||||
|
опоросилась...
|
||||||
|
- Вы не хотели бы поблагодарить за это президента?
|
||||||
|
- Да с чего ж? Пахал сам, сеял сам, растил и собирал опять таки сам - в
|
||||||
|
чем тут его заслуга.
|
||||||
|
- Как так? (жестко) А вы подумайте!
|
||||||
|
- А, ну ежли подумать, то насчет свиньи не отрицаю, тут всяко могло
|
||||||
|
быть...
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Сомалийский иммигрант прибыл в Берлин. Он останавливает первого человека, которого он видит и говорит: "Благодарю вас, господин. Германия позволила мне жить в этой стране, дала мне жилье, денег на еду, бесплатное медицинское обслуживание, бесплатное образование и никаких налогов!" Прохожий отвечает: "Вы ошибаетесь, я афганец." Человек идет дальше и встречает другого прохожего: "Спасибо за то, что такая красивая страна Германия! и т.д.". Человек говорит: "Я не немец, я иракец!" Вновь прибывший идет дальше, к следующему человеку, пожимает ему руку и говорит: "Спасибо за прекрасную Германию!" "Этот человек поднимает руку и говорит: "Я из Пакистана, я не из Германии!" Он, наконец, видит - идет милая дама. Спрашивает: "Вы немка?" Она говорит: "Нет, я из Индии!" Озадаченный, он спрашивает ее: "А где же немцы?" Индуска проверяет часы и отвечает: "Так они сейчас работают!"
|
||||||
|
|
||||||
|
Первая десятка
|
||||||
|
**********
|
||||||
|
|
||||||
|
Как сказал старый казак: если украинцев пошлют воевать с русскими, надо стать на границе спина к спине и стрелять в тех, кто послал.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Cидит Мухаммед на корточках в Берлине и плюет на землю через дырку в зубах. Вдруг появляется фея и говорит:
|
||||||
|
— Я социалистическая социальная либеральная фея! Я прилетела, чтобы исполнить три желания!
|
||||||
|
— Посмотри, какая у меня дырка во рту! Я хочу, чтобы мне вылечили и вставили все зубы!
|
||||||
|
Не успел Мухаммед произнести эти слова, как тотчас вышел закон о бесплатном лечении и протезировании зубов для социальных иностранцев, и его рот засиял белоснежной голливудской улыбкой.
|
||||||
|
— Я очень скучаю по своим четырем женам и пятнадцати детишкам, а также по родителям, братьям и сестрам, родителям-братьям-сестрам моих жен! Я хочу, чтобы мы все жили на роскошной вилле, и чтобы денег всегда много было!
|
||||||
|
Не успел Мухаммед договорить, как оказался в прекрасной вилле! На столе — текст закона о воссоединении семей для социальных иностранцев, а также банковские распечатки со сведениями о поступивших пособиях. Дом полностью меблирован и оснащен электроприборами в соответствии с законом о помощи в приобретении мебели и бытовой техники для социальных иностранцев.
|
||||||
|
Счастливый Мухаммед просто не знает, чего бы ему еще попросить, ведь одно желание еще осталось. И он попросил:
|
||||||
|
— Хочу стать настоящим немцем. Не только по гражданству. Хочу быть голубоглазым блондином, и чтоб меня звали Фриц Шульц!
|
||||||
|
Не успел он закончить фразу, как все исчезло, и он обнаружил себя вновь сидящим на корточках и плюющим на землю сквозь дырку в зубах.
|
||||||
|
— Что случилось? — спросил он у феи.
|
||||||
|
— Как не стыдно, господин Шульц, клянчить у государства! Вы должны заботиться о себе сами! Идите и ищите работу!
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
А давайте больным детям на лечение брать из бюджета, а депутатам зарплату собирать на первом канале!
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Идёт Будда с учениками по дороге. Видит: яма, в ней вол, крестьянин пытается его вытянуть, но сил не хватает. Будда кивнул ученикам, они быстро помогли вытянуть животное. Идут дальше, снова яма, в ней вол, на краю сидит крестьянин и горько плачет. Будда прошёл мимо и как бы не заметил. Ученики его спрашивают:
|
||||||
|
|
||||||
|
- Учитель, почему ты не захотел помочь этому крестьянину?
|
||||||
|
- Помочь плакать?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Воздушный шар сбился с курса, и воздухоплаватель срочно опустился с ним вниз. Увидев внизу человека, он спросил:
|
||||||
|
|
||||||
|
- Извините, где я нахожусь?
|
||||||
|
- Вы находитесь на воздушном шаре, в 15м над землей. Ваши координаты - 5°28'17" N и 100°40'19" E.
|
||||||
|
- Похоже, вы математик, - вздохнул воздухоплаватель.
|
||||||
|
- Да, я математик, - согласился прохожий. - Как вы догадались?
|
||||||
|
- Ваш ответ, по-видимому, точный и полный, но для меня совершенно бесполезный. Я по-прежнему не знаю, где я нахожусь, и что мне делать. Вы мне нисколько не помогли, только напрасно отняли время.
|
||||||
|
- А вы, похоже, из управленцев, - заметил математик.
|
||||||
|
- Я действительно топ-менеджер серьезной компании, - воспрял воздухоплаватель. - Но как вы догадались? Вы видели меня по телевизору?
|
||||||
|
- Зачем? - удивился математик. - Судите сами: вы не понимаете ни где вы находитесь, ни что вам следует делать, в этом вы полагаетесь на нижестоящих. Спрашивая совета у эксперта, вы ни на секунду не задумываетесь, способны ли вы понять его ответ, и когда оказывается, что это - не так, вы возмущаетесь вместо того, чтобы переспросить. Вы находитесь ровно в том же положении, что и до моего ответа, но теперь почему-то обвиняете в этом меня. Наконец, вы находитесь выше других только благодаря дутому пузырю, и если с ним что-то случится - падение станет для вас фатальным.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Забавно, что когда Сбербанк празднует свой юбилей, то он считает свою историю с 1841 года, а когда ему задают вопросы про вклады 1991 года, то оказывается, что это совершенно другой банк.
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
А давайте что-нибудь споем в поддержку артистов, попавших в сложную финансовую ситуацию?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Горит здание Сбербанка.
|
||||||
|
Звонок в пожарную охрану:
|
||||||
|
|
||||||
|
- Срочно приезжайте!!! Пожар в здании Сбербанка!!!
|
||||||
|
- Одну минуту, я переключу вас на специалиста
|
||||||
|
играет бодрая музыка, затем слышатся радостные фразы: "Если вы хотите узнать о наших новых услугах - нажмите "1". Если хотите заключить договор на монтаж противопожарного оборудования - нажмите "2". Внимание! Пожарная охрана представляет вам совершенно новый способ тушения пожаров! Хотите узнать больше? Нажмите "3". Не услышали свой вариант? Оставайтесь на линии. Приготовьте кадастровый номер вашего объекта, а также паспортные данные его владельца. Ваш звонок очень важен для нас - оставайтесь на линии.
|
||||||
|
Хотите попробовать потушить пожар самостоятельно? Воспользуйтесь услугой "Продвинутый пожарный"! Чтобы узнать, как подключить - нажмите "5""
|
||||||
|
........
|
||||||
|
- Оператор пожарной охраны Сергей, чем я могу вам помочь?
|
||||||
|
- У нас пожар! Горит три помещения!
|
||||||
|
- Скажите, как я могу к вам обращаться?
|
||||||
|
- Вы идиот? У нас здание горит! Не надо ко мне обращаться, срочно выезжайте тушить!
|
||||||
|
- Подскажите кадастровый номер здания и ФИО владельца
|
||||||
|
- Да не знаю я никакого кадастрового номера, я назвал вам адрес! Этого недостаточно, чтобы выехать на тушение пожара?!
|
||||||
|
- Оставайтесь на линии, я переведу вас на специалиста по поддержке
|
||||||
|
Играет бодрая музыка,"Если вы хотите заказать монтаж противопожарной сигнализации - произнесите: "Монтаж сигнализации", Если вы хотите подключиться к услуге "Круглосуточный пожарный расчет" - произнесите: "Подключиться". Вы не выбрали подходящий вариант. Ваш звонок будет переведен на оператора".
|
||||||
|
- Здравствуйте, меня зовут Александр, чем могу помочь?!
|
||||||
|
- У нас здание горит! Сделайте что-нибудь!
|
||||||
|
- Подскажите, как я могу к вам обращаться?
|
||||||
|
- Б...!!! С....! .... ....! Пожар!!!
|
||||||
|
- Наши специалисты рассмотрят вашу проблему. Скажите, по какому номеру мы можем с вами связаться?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
А разве первыми в военкомат вызывают не тех, у кого на машине наклеено: "Можем повторить!"?
|
||||||
|
|
||||||
|
**********
|
||||||
|
|
||||||
|
Не спрашивай у мужчины про его доходы, у женщины про возраст, у патриота, откуда у него американский паспорт.
|
||||||
|
|
||||||
|
**********
|
21
back/main.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"port-knocker/cmd"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version и BuildTime устанавливаются при сборке через ldflags
|
||||||
|
var (
|
||||||
|
Version = "v1.0.10"
|
||||||
|
BuildTime = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
204
back/scripts/quick-release.sh
Executable file
@@ -0,0 +1,204 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Скрипт для быстрого создания релиза Port Knocker
|
||||||
|
# Использование: ./docs/scripts/quick-release.sh v1.0.7
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Цвета для вывода
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Функция для вывода сообщений
|
||||||
|
log_info() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Проверка аргументов
|
||||||
|
VERSION=$1
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
log_error "Не указана версия!"
|
||||||
|
echo "Использование: $0 <version>"
|
||||||
|
echo "Пример: $0 v1.0.4"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Проверка формата версии
|
||||||
|
if [[ ! $VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
log_error "Неверный формат версии: $VERSION"
|
||||||
|
echo "Используйте формат: vX.Y.Z (например: v1.0.4)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Начинаем создание релиза $VERSION..."
|
||||||
|
|
||||||
|
# Проверка зависимостей
|
||||||
|
log_info "Проверяем зависимости..."
|
||||||
|
command -v go >/dev/null 2>&1 || {
|
||||||
|
log_error "Go не установлен"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Проверка версии Go
|
||||||
|
GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//')
|
||||||
|
log_info "Версия Go: $GO_VERSION"
|
||||||
|
if [[ "$(echo -e "1.20\n$GO_VERSION" | sort -V | head -n1)" != "1.20" ]]; then
|
||||||
|
log_warning "Рекомендуется Go 1.20+ для лучшей совместимости"
|
||||||
|
fi
|
||||||
|
command -v git >/dev/null 2>&1 || {
|
||||||
|
log_error "Git не установлен"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
command -v tar >/dev/null 2>&1 || {
|
||||||
|
log_error "tar не установлен"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
command -v zip >/dev/null 2>&1 || {
|
||||||
|
log_error "zip не установлен"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Проверка статуса git
|
||||||
|
log_info "Проверяем статус Git..."
|
||||||
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
|
log_warning "Есть незакоммиченные изменения!"
|
||||||
|
echo "Текущие изменения:"
|
||||||
|
git status --short
|
||||||
|
read -p "Продолжить? (y/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
log_info "Отменено пользователем"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Обновление версии
|
||||||
|
log_info "Обновляем версию в main.go..."
|
||||||
|
sed -i "s/Version.*=.*\".*\"/Version = \"$VERSION\"/" main.go
|
||||||
|
|
||||||
|
log_info "Обновляем версию в README.md..."
|
||||||
|
sed -i "s/Версия.*: [0-9.]*/Версия**: ${VERSION#v}/" README.md
|
||||||
|
|
||||||
|
# Проверка изменений
|
||||||
|
log_info "Проверяем изменения..."
|
||||||
|
if [ -z "$(git diff)" ]; then
|
||||||
|
log_warning "Нет изменений для коммита"
|
||||||
|
else
|
||||||
|
echo "Изменения:"
|
||||||
|
git diff --stat
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Коммит изменений
|
||||||
|
log_info "Коммитим изменения..."
|
||||||
|
git add .
|
||||||
|
git commit -m "Prepare for release $VERSION"
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
# Сборка бинарников
|
||||||
|
log_info "Собираем бинарники для всех платформ..."
|
||||||
|
export VERSION_NUM="${VERSION#v}"
|
||||||
|
export BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S')
|
||||||
|
|
||||||
|
# Функция сборки для платформы
|
||||||
|
build_for_platform() {
|
||||||
|
local os=$1
|
||||||
|
local arch=$2
|
||||||
|
local suffix=$3
|
||||||
|
local binary_name="port-knocker-$suffix"
|
||||||
|
|
||||||
|
log_info "Собираем для $os/$arch..."
|
||||||
|
GOOS=$os GOARCH=$arch go build \
|
||||||
|
-ldflags "-X main.Version=${VERSION_NUM} -X main.BuildTime=${BUILD_TIME} -s -w" \
|
||||||
|
-o "$binary_name" .
|
||||||
|
|
||||||
|
# Создание архива
|
||||||
|
if [[ "$os" == "windows" ]]; then
|
||||||
|
zip "${binary_name}.zip" "$binary_name"
|
||||||
|
else
|
||||||
|
tar -czf "${binary_name}.tar.gz" "$binary_name"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Удаление бинарника
|
||||||
|
rm "$binary_name"
|
||||||
|
|
||||||
|
log_success "Создан архив для $os/$arch"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Сборка для всех платформ
|
||||||
|
build_for_platform "linux" "amd64" "linux-amd64"
|
||||||
|
build_for_platform "linux" "arm64" "linux-arm64"
|
||||||
|
build_for_platform "windows" "amd64" "windows-amd64.exe"
|
||||||
|
build_for_platform "darwin" "amd64" "darwin-amd64"
|
||||||
|
build_for_platform "darwin" "arm64" "darwin-arm64"
|
||||||
|
|
||||||
|
# Проверка созданных файлов
|
||||||
|
log_info "Проверяем созданные архивы..."
|
||||||
|
ls -la port-knocker-*
|
||||||
|
|
||||||
|
# Создание Git тега
|
||||||
|
log_info "Создаем Git тег..."
|
||||||
|
# Читаем release-notes.md и сохраняем содержимое в переменную NOTES
|
||||||
|
NOTES=$(cat docs/scripts/release-notes.md)
|
||||||
|
# Заменяем все переменные вида $VERSION в NOTES на их значения
|
||||||
|
NOTES=$(echo "$NOTES" | sed "s/\\\$VERSION/$VERSION/g")
|
||||||
|
|
||||||
|
git tag -a "$VERSION" -m "$NOTES"
|
||||||
|
|
||||||
|
git push origin "$VERSION"
|
||||||
|
|
||||||
|
# Проверка GitHub CLI
|
||||||
|
if command -v gh >/dev/null 2>&1; then
|
||||||
|
log_info "GitHub CLI найден. Создаем релиз..."
|
||||||
|
|
||||||
|
# Проверка авторизации
|
||||||
|
if gh auth status >/dev/null 2>&1; then
|
||||||
|
log_info "Создаем релиз на GitHub..."
|
||||||
|
gh release create "$VERSION" \
|
||||||
|
--title "Port Knocker $VERSION" \
|
||||||
|
--notes "$NOTES" \
|
||||||
|
--draft=false \
|
||||||
|
--prerelease=false
|
||||||
|
|
||||||
|
log_info "Загружаем бинарники..."
|
||||||
|
gh release upload "$VERSION" port-knocker-*.tar.gz port-knocker-*.zip
|
||||||
|
|
||||||
|
log_success "Релиз $VERSION создан и опубликован на GitHub!"
|
||||||
|
else
|
||||||
|
log_warning "GitHub CLI не авторизован. Создайте релиз вручную."
|
||||||
|
log_info "Перейдите на: https://github.com/Direct-Dev-Ru/port-knocker/releases"
|
||||||
|
log_info "Загрузите файлы: port-knocker-*.tar.gz port-knocker-*.zip"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_warning "GitHub CLI не установлен. Создайте релиз вручную."
|
||||||
|
log_info "Перейдите на: https://github.com/Direct-Dev-Ru/port-knocker/releases"
|
||||||
|
log_info "Загрузите файлы: port-knocker-*.tar.gz port-knocker-*.zip"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Очистка
|
||||||
|
log_info "Очищаем временные файлы..."
|
||||||
|
rm -f port-knocker-*.tar.gz port-knocker-*.zip
|
||||||
|
|
||||||
|
log_success "Релиз $VERSION успешно создан!"
|
||||||
|
log_info "Тег: $VERSION"
|
||||||
|
log_info "Релиз: https://github.com/Direct-Dev-Ru/port-knocker/releases/tag/$VERSION"
|
||||||
|
|
||||||
|
echo
|
||||||
|
log_info "Следующие шаги:"
|
||||||
|
echo "1. Проверьте релиз на GitHub"
|
||||||
|
echo "2. Протестируйте скачанные бинарники"
|
||||||
|
echo "3. Обновите документацию если нужно"
|
39
back/scripts/release-notes.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Port Knocker $VERSION
|
||||||
|
|
||||||
|
## Изменения
|
||||||
|
|
||||||
|
- Обновления и исправления
|
||||||
|
- Улучшения производительности
|
||||||
|
- Обновлена документация
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
Скачайте соответствующий архив для вашей платформы:
|
||||||
|
|
||||||
|
- **Linux AMD64**: \`port-knocker-linux-amd64.tar.gz\`
|
||||||
|
- **Linux ARM64**: \`port-knocker-linux-arm64.tar.gz\`
|
||||||
|
- **Windows AMD64**: \`port-knocker-windows-amd64.exe.zip\`
|
||||||
|
- **macOS AMD64**: \`port-knocker-darwin-amd64.tar.gz\`
|
||||||
|
- **macOS ARM64**: \`port-knocker-darwin-arm64.tar.gz\`
|
||||||
|
|
||||||
|
### Использование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Инлайн цели
|
||||||
|
|
||||||
|
./port-knocker -t \"tcp:host:port;udp:host:port\" -v
|
||||||
|
|
||||||
|
# Конфигурационный файл
|
||||||
|
|
||||||
|
./port-knocker -c config.yaml -v
|
||||||
|
|
||||||
|
# Пасхалка
|
||||||
|
|
||||||
|
./port-knocker -t \"tcp:8.8.8.8:8888\"
|
||||||
|
|
||||||
|
# Шутки
|
||||||
|
|
||||||
|
./port-knocker -t \"tcp:1.1.1.1:1111\"
|
||||||
|
|
||||||
|
```
|
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
knocker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
- GO_KNOCKER_SERVE_PASS=${GO_KNOCKER_SERVE_PASS:-changeme}
|
||||||
|
- GO_KNOCKER_SERVE_PORT=8888
|
||||||
|
ports:
|
||||||
|
- "8888:8888"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
BIN
knocker-serve
Executable file
16
ui/.editorconfig
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = 100
|
||||||
|
trim_trailing_whitespace = false
|
42
ui/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
69
ui/README.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# ProjectApp
|
||||||
|
|
||||||
|
Современное веб-приложение для обеспечения GUI в браузере,
|
||||||
|
построенное на Angular 17 с использованием PrimeNG.
|
||||||
|
|
||||||
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
|
### Установка зависимостей
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск в режиме разработки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Приложение будет доступно по адресу `http://localhost:4200/`
|
||||||
|
|
||||||
|
### Сборка для продакшена
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Артефакты сборки будут сохранены в папке `dist/`
|
||||||
|
|
||||||
|
## 🏗️ Архитектура
|
||||||
|
|
||||||
|
- **Frontend**: Angular 17 с PrimeNG 17
|
||||||
|
- **Backend**: Go с Gin (отдельный проект)
|
||||||
|
- **API**: REST API для получения данных о погоде
|
||||||
|
- **Стили**: SCSS с Glassmorphism эффектами
|
||||||
|
|
||||||
|
## 🔧 Разработка
|
||||||
|
|
||||||
|
### Генерация компонентов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate component component-name
|
||||||
|
```
|
||||||
|
|
||||||
|
### Тестирование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Unit тесты
|
||||||
|
ng test
|
||||||
|
|
||||||
|
# E2E тесты
|
||||||
|
ng e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Линтинг
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверка стиля кода
|
||||||
|
ng lint
|
||||||
|
|
||||||
|
## 📦 Сборка для встраивания
|
||||||
|
|
||||||
|
Для встраивания в Go приложение:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:embed [/path/to/front] # /home/user/projects/golang/go-project/project-front
|
||||||
|
```
|
||||||
|
|
||||||
|
Файлы будут собраны в папку `/path/to/front`
|
101
ui/angular.json
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"project-front": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"style": "scss"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/project-front",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"polyfills": ["zone.js"],
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
"src/favicon.ico",
|
||||||
|
"src/assets",
|
||||||
|
"src/manifest.webmanifest"
|
||||||
|
],
|
||||||
|
"styles": ["src/styles.scss"],
|
||||||
|
"scripts": []
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "1.5mb",
|
||||||
|
"maximumError": "2mb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "128kb",
|
||||||
|
"maximumError": "256kb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all",
|
||||||
|
"serviceWorker": "ngsw-config.json",
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "project-front:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "project-front:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"extract-i18n": {
|
||||||
|
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "project-front:build"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
|
"options": {
|
||||||
|
"polyfills": ["zone.js", "zone.js/testing"],
|
||||||
|
"tsConfig": "tsconfig.spec.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
"src/favicon.ico",
|
||||||
|
"src/assets",
|
||||||
|
"src/manifest.webmanifest"
|
||||||
|
],
|
||||||
|
"styles": ["src/styles.scss"],
|
||||||
|
"scripts": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
20
ui/build-for-embeding.sh
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo "Ошибка: Пожалуйста, укажите директорию назначения."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DESTINATION_DIR=$1
|
||||||
|
|
||||||
|
echo "Building Angular app for embedding..."
|
||||||
|
# ng build --configuration production --output-path ../../golang/gin-restapi/weather-front
|
||||||
|
rm -rf "$DESTINATION_DIR"
|
||||||
|
npx ng build --configuration production
|
||||||
|
|
||||||
|
mkdir -p "$DESTINATION_DIR"
|
||||||
|
|
||||||
|
cp -r /home/su/projects/angular/project-front/dist/project-front/browser/* \
|
||||||
|
"$DESTINATION_DIR"
|
||||||
|
|
||||||
|
echo "Build completed successfully!"
|
||||||
|
echo "Frontend files are ready for embedding in Go binary"
|
30
ui/ngsw-config.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
|
||||||
|
"index": "/index.html",
|
||||||
|
"assetGroups": [
|
||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"installMode": "prefetch",
|
||||||
|
"resources": {
|
||||||
|
"files": [
|
||||||
|
"/favicon.ico",
|
||||||
|
"/index.html",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
"/*.css",
|
||||||
|
"/*.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "assets",
|
||||||
|
"installMode": "lazy",
|
||||||
|
"updateMode": "prefetch",
|
||||||
|
"resources": {
|
||||||
|
"files": [
|
||||||
|
"/assets/**",
|
||||||
|
"/media/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
13460
ui/package-lock.json
generated
Normal file
48
ui/package.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "front-project",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"build:embed": "ng build --configuration production --output-path ",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test",
|
||||||
|
"server": "http-server -p 8880 -c-1 dist/front-project/browser"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^17.3.0",
|
||||||
|
"@angular/common": "^17.3.0",
|
||||||
|
"@angular/compiler": "^17.3.0",
|
||||||
|
"@angular/core": "^17.3.0",
|
||||||
|
"@angular/forms": "^17.3.0",
|
||||||
|
"@angular/platform-browser": "^17.3.0",
|
||||||
|
"@angular/platform-browser-dynamic": "^17.3.0",
|
||||||
|
"@angular/router": "^17.3.0",
|
||||||
|
"@angular/service-worker": "^17.3.0",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"primeflex": "^3.3.1",
|
||||||
|
"primeicons": "^7.0.0",
|
||||||
|
"primeng": "^17.18.15",
|
||||||
|
"roboto-fontface": "^0.10.0",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "~0.14.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^17.3.8",
|
||||||
|
"@angular/cli": "^17.3.8",
|
||||||
|
"@angular/compiler-cli": "^17.3.0",
|
||||||
|
"@types/jasmine": "~5.1.0",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"http-server": "^14.1.1",
|
||||||
|
"jasmine-core": "~5.1.0",
|
||||||
|
"karma": "~6.4.0",
|
||||||
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
"karma-coverage": "~2.2.0",
|
||||||
|
"karma-jasmine": "~5.1.0",
|
||||||
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
|
"typescript": "~5.4.2"
|
||||||
|
}
|
||||||
|
}
|
1
ui/src/app/app.component.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet></router-outlet>
|
26
ui/src/app/app.component.scss
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
ul {
|
||||||
|
list-style-type: none; /* Remove default list styling */
|
||||||
|
padding: 0; /* Remove default padding */
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
cursor: pointer; /* Change cursor to pointer on hover */
|
||||||
|
padding: 10px; /* Add some padding for better click area */
|
||||||
|
transition: background-color 0.3s; /* Smooth transition for background color */
|
||||||
|
}
|
||||||
|
|
||||||
|
li:hover {
|
||||||
|
color: #9a5d5d; /* Change background color on hover */
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: larger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-container {
|
||||||
|
margin-top: 30px;
|
||||||
|
display: flex; /* Use Flexbox */
|
||||||
|
flex-direction: column; /* Stack children vertically */
|
||||||
|
align-items: center; /* Center horizontally */
|
||||||
|
// justify-content: center; /* Center vertically */
|
||||||
|
// height: 100vh; /* Full viewport height */
|
||||||
|
text-align: center; /* Center text */
|
||||||
|
}
|
12
ui/src/app/app.component.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, RouterOutlet],
|
||||||
|
templateUrl: './app.component.html',
|
||||||
|
styleUrl: './app.component.scss',
|
||||||
|
})
|
||||||
|
export class AppComponent {}
|
18
ui/src/app/app.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { ApplicationConfig, isDevMode } from '@angular/core';
|
||||||
|
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { provideServiceWorker } from '@angular/service-worker';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideAnimationsAsync(),
|
||||||
|
provideHttpClient(),
|
||||||
|
provideRouter(routes),
|
||||||
|
provideServiceWorker('ngsw-worker.js', {
|
||||||
|
enabled: !isDevMode(),
|
||||||
|
registrationStrategy: 'registerWhenStable:30000'
|
||||||
|
})
|
||||||
|
],
|
||||||
|
};
|
9
ui/src/app/app.routes.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
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: '' }
|
||||||
|
];
|
92
ui/src/app/basic-knock/basic-knock-page.component.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
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-basic-knock-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule, RouterModule, CardModule, ButtonModule, DialogModule, KnockPageComponent
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div class="container">
|
||||||
|
<!-- Встраиваем основной компонент в базовом режиме -->
|
||||||
|
<app-knock-page [enableFSA]="false" [canUseFSA]="canUseFSA"></app-knock-page>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Информационное модальное окно -->
|
||||||
|
<p-dialog header="📁 Базовая версия"
|
||||||
|
[(visible)]="showInfoDialog"
|
||||||
|
[modal]="true"
|
||||||
|
[closable]="true"
|
||||||
|
[draggable]="false"
|
||||||
|
[resizable]="false"
|
||||||
|
styleClass="info-dialog">
|
||||||
|
<div class="dialog-content">
|
||||||
|
<p class="mb-3">
|
||||||
|
Эта версия работает в любом браузере, но файлы загружаются/скачиваются через стандартные диалоги браузера.
|
||||||
|
</p>
|
||||||
|
<div *ngIf="canUseFSA" class="p-3 bg-blue-50 border-round">
|
||||||
|
<p class="text-sm mb-2">
|
||||||
|
💡 <strong>Доступна расширенная версия!</strong>
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mb-3">
|
||||||
|
Ваш браузер поддерживает прямое редактирование файлов на диске.
|
||||||
|
</p>
|
||||||
|
<button pButton
|
||||||
|
type="button"
|
||||||
|
label="Перейти к расширенной версии"
|
||||||
|
class="p-button-success p-button-sm"
|
||||||
|
routerLink="/fsa"
|
||||||
|
(click)="showInfoDialog = false">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</p-dialog>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-blue-50 {
|
||||||
|
background-color: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
min-width: 400px;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class BasicKnockPageComponent {
|
||||||
|
canUseFSA = false;
|
||||||
|
showInfoDialog = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.checkFSASupport();
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkFSASupport() {
|
||||||
|
const w = window as any;
|
||||||
|
this.canUseFSA = typeof w.showOpenFilePicker === 'function';
|
||||||
|
}
|
||||||
|
}
|
132
ui/src/app/fsa-knock/fsa-knock-page.component.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { CardModule } from 'primeng/card';
|
||||||
|
import { ButtonModule } from 'primeng/button';
|
||||||
|
import { DialogModule } from 'primeng/dialog';
|
||||||
|
import { KnockPageComponent } from '../knock/knock-page.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-fsa-knock-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule, RouterModule, CardModule, ButtonModule, DialogModule, KnockPageComponent
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div class="container">
|
||||||
|
<div *ngIf="!isFSASupported" class="text-center">
|
||||||
|
<h3>File System Access API не поддерживается</h3>
|
||||||
|
<p>Эта функциональность требует браузер с поддержкой File System Access API:</p>
|
||||||
|
<ul class="text-left mt-3">
|
||||||
|
<li>Google Chrome 86+</li>
|
||||||
|
<li>Microsoft Edge 86+</li>
|
||||||
|
<li>Opera 72+</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mt-3">Ваш браузер: <strong>{{ browserInfo }}</strong></p>
|
||||||
|
<button pButton
|
||||||
|
type="button"
|
||||||
|
label="Перейти к основной версии"
|
||||||
|
class="p-button-outlined mt-3"
|
||||||
|
routerLink="/">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="isFSASupported">
|
||||||
|
<!-- Встраиваем основной компонент с поддержкой FSA -->
|
||||||
|
<app-knock-page [enableFSA]="true" [canUseFSA]="true"></app-knock-page>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Информационное модальное окно -->
|
||||||
|
<p-dialog header="🚀 Расширенная версия с File System Access"
|
||||||
|
[(visible)]="showInfoDialog"
|
||||||
|
[modal]="true"
|
||||||
|
[closable]="true"
|
||||||
|
[draggable]="false"
|
||||||
|
[resizable]="false"
|
||||||
|
styleClass="info-dialog">
|
||||||
|
<div class="dialog-content">
|
||||||
|
<p class="mb-3">
|
||||||
|
Эта версия поддерживает прямое редактирование файлов на диске.
|
||||||
|
Файлы будут автоматически перезаписываться после шифрования/дешифрования.
|
||||||
|
</p>
|
||||||
|
<div class="p-3 bg-green-50 border-round">
|
||||||
|
<p class="text-sm mb-2">
|
||||||
|
✅ <strong>Доступные возможности:</strong>
|
||||||
|
</p>
|
||||||
|
<ul class="text-sm mb-0">
|
||||||
|
<li>Прямое открытие файлов с диска</li>
|
||||||
|
<li>Автоматическое сохранение изменений</li>
|
||||||
|
<li>Перезапись зашифрованных файлов "на месте"</li>
|
||||||
|
<li>Быстрая работа без диалогов загрузки/скачивания</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</p-dialog>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
display: inline-block;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-link {
|
||||||
|
color: #3b82f6;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-link:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-green-50 {
|
||||||
|
background-color: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
min-width: 450px;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class FsaKnockPageComponent {
|
||||||
|
isFSASupported = false;
|
||||||
|
browserInfo = '';
|
||||||
|
showInfoDialog = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.checkFSASupport();
|
||||||
|
this.getBrowserInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkFSASupport() {
|
||||||
|
const w = window as any;
|
||||||
|
this.isFSASupported = typeof w.showOpenFilePicker === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBrowserInfo() {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
if (ua.includes('Chrome') && !ua.includes('Edg/')) {
|
||||||
|
this.browserInfo = 'Google Chrome';
|
||||||
|
} else if (ua.includes('Edg/')) {
|
||||||
|
this.browserInfo = 'Microsoft Edge';
|
||||||
|
} else if (ua.includes('Opera') || ua.includes('OPR/')) {
|
||||||
|
this.browserInfo = 'Opera';
|
||||||
|
} else if (ua.includes('Firefox')) {
|
||||||
|
this.browserInfo = 'Mozilla Firefox';
|
||||||
|
} else if (ua.includes('Safari') && !ua.includes('Chrome')) {
|
||||||
|
this.browserInfo = 'Safari';
|
||||||
|
} else {
|
||||||
|
this.browserInfo = 'Неизвестный браузер';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
317
ui/src/app/knock/knock-page.component.html
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
<div class="container">
|
||||||
|
<p-card [header]="cardHeader">
|
||||||
|
<ng-template pTemplate="header">
|
||||||
|
<div class="flex justify-content-between align-items-center">
|
||||||
|
<h1 style="margin-left: 1rem">Port Knocker</h1>
|
||||||
|
<!-- <div class="animated-title" [class.animating]="isAnimating">
|
||||||
|
<span *ngIf="cardHeader">{{ cardHeader }}</span>
|
||||||
|
</div> -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
*ngIf="!enableFSA"
|
||||||
|
pButton
|
||||||
|
type="button"
|
||||||
|
label="📁 Info"
|
||||||
|
class="p-button-text p-button-sm"
|
||||||
|
(click)="showInfoDialog = true"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
*ngIf="canUseFSA && !enableFSA"
|
||||||
|
pButton
|
||||||
|
type="button"
|
||||||
|
label="🚀 FSA Version"
|
||||||
|
class="p-button-text p-button-sm"
|
||||||
|
routerLink="/fsa"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
<form [formGroup]="form" (ngSubmit)="execute()" class="p-fluid">
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-12 md:col-6">
|
||||||
|
<label>Password</label>
|
||||||
|
<p-password
|
||||||
|
formControlName="password"
|
||||||
|
[feedback]="false"
|
||||||
|
toggleMask
|
||||||
|
inputStyleClass="w-full"
|
||||||
|
placeholder="GO_KNOCKER_SERVE_PASS"
|
||||||
|
></p-password>
|
||||||
|
<div class="mt-1 text-sm" *ngIf="!form.value.password || wrongPass">
|
||||||
|
<span class="text-red-500" *ngIf="wrongPass">Invalid password</span>
|
||||||
|
<span class="text-600" *ngIf="!wrongPass && !form.value.password"
|
||||||
|
>Password is required</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 md:col-6">
|
||||||
|
<label>Mode</label>
|
||||||
|
<p-dropdown
|
||||||
|
formControlName="mode"
|
||||||
|
[options]="[
|
||||||
|
{ label: 'Inline', value: 'inline' },
|
||||||
|
{ label: 'YAML', value: 'yaml' }
|
||||||
|
]"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
class="w-full"
|
||||||
|
></p-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12" *ngIf="form.value.mode === 'inline'">
|
||||||
|
<label>Targets</label>
|
||||||
|
<input
|
||||||
|
pInputText
|
||||||
|
type="text"
|
||||||
|
formControlName="targets"
|
||||||
|
placeholder="tcp:host:port;udp:host:port"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 md:col-4" *ngIf="form.value.mode === 'inline'">
|
||||||
|
<label>Delay</label>
|
||||||
|
<input
|
||||||
|
pInputText
|
||||||
|
type="text"
|
||||||
|
formControlName="delay"
|
||||||
|
placeholder="1s"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6 md:col-4 flex align-items-center gap-2">
|
||||||
|
<p-checkbox formControlName="verbose" [binary]="true"></p-checkbox>
|
||||||
|
<label class="checkbox-label">Verbose</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6 md:col-4 flex align-items-center gap-2">
|
||||||
|
<p-checkbox
|
||||||
|
formControlName="waitConnection"
|
||||||
|
[binary]="true"
|
||||||
|
></p-checkbox>
|
||||||
|
<label class="checkbox-label">Wait connection</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<label>Gateway</label>
|
||||||
|
<input
|
||||||
|
pInputText
|
||||||
|
type="text"
|
||||||
|
formControlName="gateway"
|
||||||
|
placeholder="optional local ip:port"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12" *ngIf="form.value.mode === 'yaml'">
|
||||||
|
<label>YAML</label>
|
||||||
|
<textarea
|
||||||
|
pInputTextarea
|
||||||
|
formControlName="configYAML"
|
||||||
|
rows="12"
|
||||||
|
placeholder="paste YAML or ENCRYPTED:"
|
||||||
|
class="w-full"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File controls directly under YAML -->
|
||||||
|
<div class="col-12" *ngIf="form.value.mode === 'yaml'">
|
||||||
|
<div class="flex flex-wrap gap-2 align-items-center">
|
||||||
|
<!-- FSA version -->
|
||||||
|
<button
|
||||||
|
*ngIf="enableFSA"
|
||||||
|
pButton
|
||||||
|
type="button"
|
||||||
|
label="Open File (with write access)"
|
||||||
|
(click)="openFileWithWriteAccess()"
|
||||||
|
class="p-button-outlined"
|
||||||
|
></button>
|
||||||
|
<span
|
||||||
|
*ngIf="enableFSA && selectedFileName"
|
||||||
|
class="text-sm text-600"
|
||||||
|
>{{ selectedFileName }}</span
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Basic version -->
|
||||||
|
<p-fileUpload
|
||||||
|
*ngIf="!enableFSA"
|
||||||
|
mode="basic"
|
||||||
|
name="file"
|
||||||
|
chooseLabel="Choose File"
|
||||||
|
(onSelect)="onFileUpload($event)"
|
||||||
|
[customUpload]="true"
|
||||||
|
[auto]="false"
|
||||||
|
accept=".yaml,.yml,.txt,.encrypted"
|
||||||
|
[maxFileSize]="1048576"
|
||||||
|
></p-fileUpload>
|
||||||
|
<input
|
||||||
|
*ngIf="!enableFSA && !isYamlEncrypted()"
|
||||||
|
pInputText
|
||||||
|
type="text"
|
||||||
|
class="w-full md:w-6"
|
||||||
|
placeholder="Server file path (optional)"
|
||||||
|
formControlName="serverFilePath"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 1: Execute full width -->
|
||||||
|
<div class="col-12">
|
||||||
|
<button
|
||||||
|
pButton
|
||||||
|
type="submit"
|
||||||
|
label="Execute"
|
||||||
|
class="w-full"
|
||||||
|
[loading]="executing"
|
||||||
|
[disabled]="executing || !form.value.password || wrongPass"
|
||||||
|
[ngClass]="{ 'p-button-danger': !form.value.password || wrongPass }"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<!-- Row 2: Encrypt / Decrypt half/half on desktop; stacked on mobile (только для YAML режима) -->
|
||||||
|
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
|
||||||
|
<button
|
||||||
|
pButton
|
||||||
|
type="button"
|
||||||
|
label="Encrypt"
|
||||||
|
(click)="encrypt()"
|
||||||
|
class="p-button-secondary w-full"
|
||||||
|
[disabled]="
|
||||||
|
executing ||
|
||||||
|
!form.value.password ||
|
||||||
|
wrongPass ||
|
||||||
|
isYamlEncrypted()
|
||||||
|
"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
|
||||||
|
<button
|
||||||
|
pButton
|
||||||
|
type="button"
|
||||||
|
label="Decrypt"
|
||||||
|
(click)="decrypt()"
|
||||||
|
class="p-button-secondary w-full"
|
||||||
|
[disabled]="
|
||||||
|
executing ||
|
||||||
|
!form.value.password ||
|
||||||
|
wrongPass ||
|
||||||
|
!isYamlEncrypted()
|
||||||
|
"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<!-- Row 3: Download actions half/half on desktop; stacked on mobile (только для YAML режима) -->
|
||||||
|
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
|
||||||
|
<button
|
||||||
|
pButton
|
||||||
|
type="button"
|
||||||
|
label="Download YAML"
|
||||||
|
(click)="downloadYaml()"
|
||||||
|
class="p-button-text w-full"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
|
||||||
|
<button
|
||||||
|
pButton
|
||||||
|
type="button"
|
||||||
|
label="Download Result"
|
||||||
|
(click)="downloadResult()"
|
||||||
|
class="p-button-text w-full"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p-divider></p-divider>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-12">
|
||||||
|
<p-progressBar
|
||||||
|
[value]="executing ? 100 : 0"
|
||||||
|
[mode]="executing ? 'indeterminate' : 'determinate'"
|
||||||
|
></p-progressBar>
|
||||||
|
<div class="mt-2 text-600" *ngIf="executing">
|
||||||
|
Elapsed: {{ elapsedMs / 1000 | number : "1.1-1" }}s
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-600" *ngIf="!executing && elapsedMs > 0">
|
||||||
|
Last run: {{ elapsedMs / 1000 | number : "1.1-1" }}s
|
||||||
|
<span *ngIf="lastRunTime" class="ml-2 text-500">
|
||||||
|
({{ lastRunTime | date : "short" }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</p-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно с результатом выполнения -->
|
||||||
|
<p-dialog
|
||||||
|
header="Результат выполнения"
|
||||||
|
[(visible)]="showResultDialog"
|
||||||
|
[modal]="true"
|
||||||
|
[closable]="true"
|
||||||
|
[draggable]="false"
|
||||||
|
[resizable]="false"
|
||||||
|
styleClass="result-dialog"
|
||||||
|
>
|
||||||
|
<div class="dialog-content">
|
||||||
|
<div *ngIf="result" class="mb-3">
|
||||||
|
<h4 class="text-green-600 mb-2">✅ Успешно выполнено</h4>
|
||||||
|
<pre class="bg-gray-50 p-3 border-round text-sm">{{ result }}</pre>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="error" class="mb-3">
|
||||||
|
<h4 class="text-red-600 mb-2">❌ Ошибка</h4>
|
||||||
|
<pre class="bg-red-50 p-3 border-round text-sm text-red-700">{{
|
||||||
|
error
|
||||||
|
}}</pre>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="lastRunTime" class="text-sm text-600">
|
||||||
|
Время выполнения: {{ elapsedMs / 1000 | number : "1.1-1" }}s
|
||||||
|
<br />
|
||||||
|
Завершено: {{ lastRunTime | date : "short" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ng-template pTemplate="footer">
|
||||||
|
<button
|
||||||
|
pButton
|
||||||
|
type="button"
|
||||||
|
label="OK"
|
||||||
|
class="p-button-primary"
|
||||||
|
(click)="showResultDialog = false"
|
||||||
|
></button>
|
||||||
|
</ng-template>
|
||||||
|
</p-dialog>
|
||||||
|
|
||||||
|
<!-- Информационное модальное окно -->
|
||||||
|
<p-dialog
|
||||||
|
header="📁 Базовая версия"
|
||||||
|
[(visible)]="showInfoDialog"
|
||||||
|
[modal]="true"
|
||||||
|
[closable]="true"
|
||||||
|
[draggable]="false"
|
||||||
|
[resizable]="false"
|
||||||
|
styleClass="info-dialog"
|
||||||
|
>
|
||||||
|
<div class="dialog-content">
|
||||||
|
<p class="mb-3">
|
||||||
|
Эта версия работает в любом браузере, но файлы загружаются/скачиваются
|
||||||
|
через стандартные диалоги браузера.
|
||||||
|
</p>
|
||||||
|
<div *ngIf="canUseFSA" class="p-3 bg-blue-50 border-round">
|
||||||
|
<p class="text-sm mb-2">
|
||||||
|
💡 <strong>Доступна расширенная версия!</strong>
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mb-3">
|
||||||
|
Ваш браузер поддерживает прямое редактирование файлов на диске.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
pButton
|
||||||
|
type="button"
|
||||||
|
label="Перейти к расширенной версии"
|
||||||
|
class="p-button-success p-button-sm"
|
||||||
|
routerLink="/fsa"
|
||||||
|
(click)="showInfoDialog = false"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</p-dialog>
|
132
ui/src/app/knock/knock-page.component.scss
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 24px auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
input[type='text'], input[type='password'], select, textarea {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.result, .error {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
min-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-blue-50 {
|
||||||
|
background-color: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-dialog {
|
||||||
|
.p-dialog {
|
||||||
|
max-width: 90vw !important;
|
||||||
|
width: 600px !important;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
width: 95vw !important;
|
||||||
|
max-width: 95vw !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-dialog-content {
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 100%;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Анимированный заголовок
|
||||||
|
.animated-title {
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
background: linear-gradient(45deg, #667eea 0%, #764ba2 100%);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
animation: gradientShift 3s ease-in-out infinite;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&.animating {
|
||||||
|
animation: gradientShift 1s ease-in-out infinite, glow 0.5s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradientShift {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow {
|
||||||
|
from {
|
||||||
|
text-shadow: 0 0 5px rgba(102, 126, 234, 0.5), 0 0 10px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
text-shadow: 0 0 10px rgba(102, 126, 234, 0.8), 0 0 20px rgba(102, 126, 234, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
780
ui/src/app/knock/knock-page.component.ts
Normal file
@@ -0,0 +1,780 @@
|
|||||||
|
import { Component, inject, Input } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators, FormsModule } 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']
|
||||||
|
})
|
||||||
|
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;
|
||||||
|
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'],
|
||||||
|
delay: ['1s'],
|
||||||
|
verbose: [true],
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
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({
|
||||||
|
next: () => {
|
||||||
|
this.executing = false;
|
||||||
|
this.stopTimer();
|
||||||
|
this.lastRunTime = new Date();
|
||||||
|
this.result = `Done in ${(this.elapsedMs/1000).toFixed(2)}s`;
|
||||||
|
this.showResultDialog = true;
|
||||||
|
},
|
||||||
|
error: (e: HttpErrorResponse) => {
|
||||||
|
this.executing = false;
|
||||||
|
this.stopTimer();
|
||||||
|
if (e.status === 401) {
|
||||||
|
this.wrongPass = true;
|
||||||
|
}
|
||||||
|
this.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();
|
||||||
|
this.clearTimer();
|
||||||
|
this.timerId = setInterval(() => {
|
||||||
|
this.elapsedMs = Date.now() - this.startTs;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopTimer() {
|
||||||
|
if (this.startTs > 0) {
|
||||||
|
this.elapsedMs = Date.now() - this.startTs;
|
||||||
|
}
|
||||||
|
this.clearTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimer() {
|
||||||
|
if (this.timerId) {
|
||||||
|
clearInterval(this.timerId);
|
||||||
|
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 между словами
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
0
ui/src/assets/.gitkeep
Normal file
BIN
ui/src/assets/icons/icon-128x128.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
ui/src/assets/icons/icon-144x144.png
Normal file
After Width: | Height: | Size: 3.0 KiB |
BIN
ui/src/assets/icons/icon-152x152.png
Normal file
After Width: | Height: | Size: 3.2 KiB |
BIN
ui/src/assets/icons/icon-192x192.png
Normal file
After Width: | Height: | Size: 4.2 KiB |
BIN
ui/src/assets/icons/icon-384x384.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
ui/src/assets/icons/icon-512x512.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
ui/src/assets/icons/icon-72x72.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
ui/src/assets/icons/icon-96x96.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
16
ui/src/environments/environment.prod.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
const logFunction = (...messages: any[]) => {};
|
||||||
|
const errorLogFunction = (...messages: any[]) => {};
|
||||||
|
|
||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
apiBaseUrl: '/api/v1',
|
||||||
|
adminApiUrl: '/api/v1/project',
|
||||||
|
log: logFunction,
|
||||||
|
errLog: errorLogFunction,
|
||||||
|
debugAny: (
|
||||||
|
something: any,
|
||||||
|
transformer: (...args: any[]) => any = (...args: any[]): any => {
|
||||||
|
return args[0];
|
||||||
|
}
|
||||||
|
) => transformer(something),
|
||||||
|
};
|
26
ui/src/environments/environment.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
|
||||||
|
const logFunction = (...messages: any[]) => {
|
||||||
|
messages.forEach((msg) => console.log(msg));
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorLogFunction = (...messages: any[]) => {
|
||||||
|
messages.forEach((msg) => console.error(msg));
|
||||||
|
};
|
||||||
|
|
||||||
|
const debugAny = (
|
||||||
|
something: any,
|
||||||
|
transformer: (...args: any[]) => any = (...args: any[]): any => {
|
||||||
|
return args[0];
|
||||||
|
}
|
||||||
|
) => transformer(something);
|
||||||
|
|
||||||
|
|
||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
apiBaseUrl: 'http://localhost:8080/api/v1',
|
||||||
|
adminApiUrl: 'http://localhost:8080/api/v1/project',
|
||||||
|
log: logFunction,
|
||||||
|
errLog: errorLogFunction,
|
||||||
|
debugAny
|
||||||
|
};
|
BIN
ui/src/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
16
ui/src/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Port-Knocker UI</title>
|
||||||
|
<base href="/">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
<meta name="theme-color" content="#1976d2">
|
||||||
|
</head>
|
||||||
|
<body class="mat-typography">
|
||||||
|
<app-root></app-root>
|
||||||
|
<noscript>Please enable JavaScript to continue using this application.</noscript>
|
||||||
|
</body>
|
||||||
|
</html>
|
6
ui/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
import { AppComponent } from './app/app.component';
|
||||||
|
|
||||||
|
bootstrapApplication(AppComponent, appConfig)
|
||||||
|
.catch((err) => console.error(err));
|
59
ui/src/manifest.webmanifest
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "weather-app",
|
||||||
|
"short_name": "weather-app",
|
||||||
|
"theme_color": "#1976d2",
|
||||||
|
"background_color": "#fafafa",
|
||||||
|
"display": "standalone",
|
||||||
|
"scope": "./",
|
||||||
|
"start_url": "./",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-72x72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-96x96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-128x128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-144x144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-152x152.png",
|
||||||
|
"sizes": "152x152",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-384x384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
46
ui/src/styles.scss
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/* You can add global styles to this file, and also import other style files */
|
||||||
|
|
||||||
|
/* PrimeNG */
|
||||||
|
@import "primeng/resources/themes/lara-light-blue/theme.css";
|
||||||
|
// @import "primeng/resources/themes/saga-blue/theme.css";
|
||||||
|
// @import "primeng/resources/themes/nova/theme.css";
|
||||||
|
@import "primeng/resources/primeng.css";
|
||||||
|
@import "primeflex/primeflex.css";
|
||||||
|
|
||||||
|
/* PrimeIcons */
|
||||||
|
@import "primeicons/primeicons.css";
|
||||||
|
|
||||||
|
/* Roboto local (roboto-fontface) */
|
||||||
|
@import "roboto-fontface/css/roboto/roboto-fontface.css";
|
||||||
|
|
||||||
|
/* Override PrimeNG font to use Roboto instead of Inter */
|
||||||
|
:root {
|
||||||
|
--font-family: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif !important;
|
||||||
|
font-family: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для label элементов */
|
||||||
|
label {
|
||||||
|
display: block !important;
|
||||||
|
margin-bottom: 0.5rem !important;
|
||||||
|
padding-left: 0.5rem !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
color: var(--text-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Исключение для label элементов рядом с checkbox */
|
||||||
|
label.checkbox-label {
|
||||||
|
display: inline !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
padding-left: 0 !important;
|
||||||
|
}
|
14
ui/tsconfig.app.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/main.ts"
|
||||||
|
],
|
||||||
|
"include": [
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
32
ui/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist/out-tsc",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": false,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"dom"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
}
|
||||||
|
}
|
14
ui/tsconfig.spec.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/spec",
|
||||||
|
"types": [
|
||||||
|
"jasmine"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|