This commit is contained in:
66 changed files with 19815 additions and 0 deletions

258
back/cmd/crypto_routes.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
}