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"
|
||||
]
|
||||
}
|