From 611bd17ac164836b868b9c2ecf4dccc370a13527 Mon Sep 17 00:00:00 2001 From: Anton Kuznetcov Date: Mon, 27 Oct 2025 18:48:49 +0600 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D0=B2=D0=B5=D1=82=D0=BA?= =?UTF-8?q?=D0=B5=20auth-feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .goreleaser.yaml | 57 ---- .vscode/settings.json | 3 + VERSION.txt | 2 +- build-conditions.yaml | 1 + build.sh | 58 ++++ cmd/history.go | 20 +- config/config.go | 86 +++-- deploy/.goreleaser.yaml | 29 ++ deploy/0.create_sealed_secrets.example.sh | 21 ++ deploy/0.namespace.yaml | 7 + deploy/1.configmap.yaml | 46 +++ deploy/2.gitrepository.yaml | 12 + deploy/3.lcg-kustomization.yaml | 19 ++ deploy/4.build-binaries.sh | 114 +++++++ deploy/4.pvc.yaml | 12 + deploy/5.build-docker.sh | 183 ++++++++++ deploy/6.full-build.sh | 202 +++++++++++ deploy/Dockerfile | 39 +++ deploy/VERSION.txt | 1 + deploy/deployment.tmpl.yaml | 95 ++++++ deploy/hpa.yaml | 42 +++ deploy/ingress-route.tmpl.yaml | 64 ++++ deploy/kustomization.tmpl.yaml | 25 ++ deploy/service.tmpl.yaml | 18 + API_CONTRACT.md => docs/API_CONTRACT.md | 0 API_GUIDE.md => docs/API_GUIDE.md | 0 CONFIG_COMMAND.md => docs/CONFIG_COMMAND.md | 0 docs/CSRF_TESTING_GUIDE.md | 231 +++++++++++++ README.md => docs/README.md | 0 RELEASE_GUIDE.md => docs/RELEASE_GUIDE.md | 0 docs/REVERSE_PROXY_GUIDE.md | 258 ++++++++++++++ ROADMAP.md => docs/ROADMAP.md | 0 .../SECURITY_FEATURES.md | 0 USAGE_GUIDE.md => docs/USAGE_GUIDE.md | 234 ++++++++++++- .../VALIDATION_CONFIG.md | 0 .../VERBOSE_PROMPT_EDITING.md | 0 go.mod | 6 +- go.sum | 2 + gpt/builtin_prompts.go | 11 +- gpt/builtin_prompts_windows.yaml | 262 ++++++++++++++ gpt/prompts.go | 37 +- gpt/providers.go | 6 +- kustomize/configmap.yaml | 46 +++ kustomize/deployment.yaml | 95 ++++++ kustomize/ingress-route.yaml | 64 ++++ kustomize/kustomization.yaml | 25 ++ kustomize/secret.yaml | 18 + kustomize/service.yaml | 18 + main.go | 57 +++- main_test.go | 1 - serve/auth.go | 269 +++++++++++++++ serve/csrf.go | 263 ++++++++++++++ serve/execute_page.go | 49 ++- serve/history.go | 37 +- serve/history_utils.go | 13 +- serve/login.go | 103 ++++++ serve/middleware.go | 112 ++++++ serve/prompts.go | 4 + serve/results.go | 80 ++--- serve/serve.go | 152 ++++++--- serve/templates/execute.css.go | 8 + serve/templates/execute.go | 13 +- serve/templates/execute.js.go | 8 +- serve/templates/history.go | 14 +- serve/templates/history_view.go | 8 +- serve/templates/login.go | 323 ++++++++++++++++++ serve/templates/prompts.go | 20 +- serve/templates/results.go | 16 +- shell-code/docker-proxy-max.sh | 16 + test_csrf.sh | 158 +++++++++ 71 files changed, 3936 insertions(+), 258 deletions(-) delete mode 100644 .goreleaser.yaml create mode 100644 .vscode/settings.json create mode 100644 build-conditions.yaml create mode 100755 build.sh create mode 100644 deploy/.goreleaser.yaml create mode 100644 deploy/0.create_sealed_secrets.example.sh create mode 100644 deploy/0.namespace.yaml create mode 100644 deploy/1.configmap.yaml create mode 100644 deploy/2.gitrepository.yaml create mode 100644 deploy/3.lcg-kustomization.yaml create mode 100755 deploy/4.build-binaries.sh create mode 100644 deploy/4.pvc.yaml create mode 100755 deploy/5.build-docker.sh create mode 100755 deploy/6.full-build.sh create mode 100644 deploy/Dockerfile create mode 100644 deploy/VERSION.txt create mode 100644 deploy/deployment.tmpl.yaml create mode 100644 deploy/hpa.yaml create mode 100644 deploy/ingress-route.tmpl.yaml create mode 100644 deploy/kustomization.tmpl.yaml create mode 100644 deploy/service.tmpl.yaml rename API_CONTRACT.md => docs/API_CONTRACT.md (100%) rename API_GUIDE.md => docs/API_GUIDE.md (100%) rename CONFIG_COMMAND.md => docs/CONFIG_COMMAND.md (100%) create mode 100644 docs/CSRF_TESTING_GUIDE.md rename README.md => docs/README.md (100%) rename RELEASE_GUIDE.md => docs/RELEASE_GUIDE.md (100%) create mode 100644 docs/REVERSE_PROXY_GUIDE.md rename ROADMAP.md => docs/ROADMAP.md (100%) rename SECURITY_FEATURES.md => docs/SECURITY_FEATURES.md (100%) rename USAGE_GUIDE.md => docs/USAGE_GUIDE.md (68%) rename VALIDATION_CONFIG.md => docs/VALIDATION_CONFIG.md (100%) rename VERBOSE_PROMPT_EDITING.md => docs/VERBOSE_PROMPT_EDITING.md (100%) create mode 100644 gpt/builtin_prompts_windows.yaml create mode 100644 kustomize/configmap.yaml create mode 100644 kustomize/deployment.yaml create mode 100644 kustomize/ingress-route.yaml create mode 100644 kustomize/kustomization.yaml create mode 100644 kustomize/secret.yaml create mode 100644 kustomize/service.yaml delete mode 100644 main_test.go create mode 100644 serve/auth.go create mode 100644 serve/csrf.go create mode 100644 serve/login.go create mode 100644 serve/middleware.go create mode 100644 serve/templates/login.go create mode 100644 shell-code/docker-proxy-max.sh create mode 100755 test_csrf.sh diff --git a/.gitignore b/.gitignore index f353b47..81b0747 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ gpt_results shell-code/jwt.admin.token run.sh lcg_history.json +deploy/0.create_sealed_secrets.sh diff --git a/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index 8dcc93e..0000000 --- a/.goreleaser.yaml +++ /dev/null @@ -1,57 +0,0 @@ -# This is an example .goreleaser.yml file with some sensible defaults. -# Make sure to check the documentation at https://goreleaser.com - -# The lines below are called `modelines`. See `:help modeline` -# Feel free to remove those if you don't want/need to use them. -# yaml-language-server: $schema=https://goreleaser.com/static/schema.json -# vim: set ts=2 sw=2 tw=0 fo=cnqoj - -version: 2 - -before: - hooks: - # You may remove this if you don't use go modules. - - go mod tidy - # you may remove this if you don't need go generate - - go generate ./... - -builds: - - binary: lcg - env: - - CGO_ENABLED=0 - goarch: - - amd64 - - arm64 - - arm - goos: - - linux - - darwin - -archives: - - formats: [tar.gz] - # this name template makes the OS and Arch compatible with the results of `uname`. - name_template: >- - {{ .ProjectName }}_ - {{- title .Os }}_ - {{- if eq .Arch "amd64" }}x86_64 - {{- else if eq .Arch "386" }}i386 - {{- else }}{{ .Arch }}{{ end }} - {{- if .Arm }}v{{ .Arm }}{{ end }} - # use zip for windows archives - format_overrides: - - goos: windows - formats: [zip] - -changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" - -release: - footer: >- - - --- - - Released by [GoReleaser](https://github.com/goreleaser/goreleaser). diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6eb0ae9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "djlint.showInstallError": false +} \ No newline at end of file diff --git a/VERSION.txt b/VERSION.txt index 0ac852d..f3b15f3 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -v2.0.1 +v2.0.2 diff --git a/build-conditions.yaml b/build-conditions.yaml new file mode 100644 index 0000000..3bc19e9 --- /dev/null +++ b/build-conditions.yaml @@ -0,0 +1 @@ +no-serve: false \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..8baa59c --- /dev/null +++ b/build.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# 🚀 LCG Build Script (Root) +# Скрипт для сборки из корневой директории проекта + +set -e + +# Цвета для вывода +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Функция для вывода сообщений +log() { + echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" +} + +success() { + echo -e "${GREEN}✅ $1${NC}" +} + +warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +error() { + echo -e "${RED}❌ $1${NC}" +} + +# Параметры +REPOSITORY=${1:-"your-registry.com/lcg"} +VERSION=${2:-"latest"} +PLATFORMS=${3:-"linux/amd64,linux/arm64"} + +log "🚀 Сборка LCG из корневой директории..." + +# Проверяем, что мы в корневой директории +if [ ! -f "go.mod" ]; then + error "Этот скрипт должен запускаться из корневой директории проекта (где находится go.mod)" + exit 1 +fi + +# Записываем версию в файл VERSION.txt +echo "$VERSION" > VERSION.txt +log "📝 Версия записана в VERSION.txt: $VERSION" + +# Запускаем полную сборку +log "🚀 Запуск полной сборки..." +./deploy/full-build.sh "$REPOSITORY" "$VERSION" "$PLATFORMS" + +if [ $? -eq 0 ]; then + success "🎉 Сборка завершена успешно!" +else + error "Ошибка при сборке" + exit 1 +fi diff --git a/cmd/history.go b/cmd/history.go index 667a92d..20b5ea7 100644 --- a/cmd/history.go +++ b/cmd/history.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "time" ) @@ -20,9 +21,20 @@ type HistoryEntry struct { func read(historyPath string) ([]HistoryEntry, error) { data, err := os.ReadFile(historyPath) - if err != nil || len(data) == 0 { + if err != nil { + // Если файл не существует, создаем пустой файл истории + if os.IsNotExist(err) { + emptyHistory := []HistoryEntry{} + if writeErr := write(historyPath, emptyHistory); writeErr != nil { + return nil, fmt.Errorf("не удалось создать файл истории: %v", writeErr) + } + return emptyHistory, nil + } return nil, err } + if len(data) == 0 { + return []HistoryEntry{}, nil + } var items []HistoryEntry if err := json.Unmarshal(data, &items); err != nil { return nil, err @@ -50,6 +62,12 @@ func ShowHistory(historyPath string, printColored func(string, string), colorYel printColored("📝 История пуста\n", colorYellow) return } + + // Сортируем записи по времени в убывающем порядке (новые сначала) + sort.Slice(items, func(i, j int) bool { + return items[i].Timestamp.After(items[j].Timestamp) + }) + printColored("📝 История (из файла):\n", colorYellow) for _, h := range items { ts := h.Timestamp.Format("2006-01-02 15:04:05") diff --git a/config/config.go b/config/config.go index 32f889b..0544633 100644 --- a/config/config.go +++ b/config/config.go @@ -3,6 +3,7 @@ package config import ( "os" "path" + "slices" "strconv" "strings" ) @@ -11,10 +12,12 @@ type Config struct { Cwd string Host string ProxyUrl string + AppName string Completions string Model string Prompt string ApiKeyFile string + ConfigFolder string ResultFolder string PromptFolder string ProviderType string @@ -39,12 +42,21 @@ type MainFlags struct { } type ServerConfig struct { - Port string - Host string - ConfigFolder string - AllowHTTP bool - SSLCertFile string - SSLKeyFile string + Port string + Host string + HealthUrl string + ProxyUrl string + BasePath string + ConfigFolder string + AllowHTTP bool + SSLCertFile string + SSLKeyFile string + RequireAuth bool + Password string + Domain string + CookieSecure bool + CookiePath string + CookieTTLHours int } type ValidationConfig struct { @@ -87,12 +99,7 @@ func getServerAllowHTTP() bool { func isSecureHost(host string) bool { secureHosts := []string{"localhost", "127.0.0.1", "::1"} - for _, secureHost := range secureHosts { - if host == secureHost { - return true - } - } - return false + return slices.Contains(secureHosts, host) } func Load() Config { @@ -102,14 +109,21 @@ func Load() Config { if err != nil { homedir = cwd } - os.MkdirAll(path.Join(homedir, ".config", "lcg", "gpt_results"), 0755) - resultFolder := getEnv("LCG_RESULT_FOLDER", path.Join(homedir, ".config", "lcg", "gpt_results")) + privateResultsDir := path.Join(homedir, ".config", "lcg", "gpt_results") + os.MkdirAll(privateResultsDir, 0700) + resultFolder := getEnv("LCG_RESULT_FOLDER", privateResultsDir) - os.MkdirAll(path.Join(homedir, ".config", "lcg", "gpt_sys_prompts"), 0755) - promptFolder := getEnv("LCG_PROMPT_FOLDER", path.Join(homedir, ".config", "lcg", "gpt_sys_prompts")) + privatePromptsDir := path.Join(homedir, ".config", "lcg", "gpt_sys_prompts") + os.MkdirAll(privatePromptsDir, 0700) + promptFolder := getEnv("LCG_PROMPT_FOLDER", privatePromptsDir) + + privateConfigDir := path.Join(homedir, ".config", "lcg", "config") + os.MkdirAll(privateConfigDir, 0700) + configFolder := getEnv("LCG_CONFIG_FOLDER", privateConfigDir) return Config{ Cwd: cwd, + AppName: getEnv("LCG_APP_NAME", "Linux Command GPT"), Host: getEnv("LCG_HOST", "http://192.168.87.108:11434/"), ProxyUrl: getEnv("LCG_PROXY_URL", "/api/v1/protected/sberchat/chat"), Completions: getEnv("LCG_COMPLETIONS_PATH", "api/chat"), @@ -118,6 +132,7 @@ func Load() Config { ApiKeyFile: getEnv("LCG_API_KEY_FILE", ".openai_api_key"), ResultFolder: resultFolder, PromptFolder: promptFolder, + ConfigFolder: configFolder, ProviderType: getEnv("LCG_PROVIDER", "ollama"), JwtToken: getEnv("LCG_JWT_TOKEN", ""), PromptID: getEnv("LCG_PROMPT_ID", "1"), @@ -126,12 +141,21 @@ func Load() Config { NoHistoryEnv: getEnv("LCG_NO_HISTORY", ""), AllowExecution: isAllowExecutionEnabled(), Server: ServerConfig{ - Port: getEnv("LCG_SERVER_PORT", "8080"), - Host: getEnv("LCG_SERVER_HOST", "localhost"), - ConfigFolder: getEnv("LCG_CONFIG_FOLDER", path.Join(homedir, ".config", "lcg", "config")), - AllowHTTP: getServerAllowHTTP(), - SSLCertFile: getEnv("LCG_SERVER_SSL_CERT_FILE", ""), - SSLKeyFile: getEnv("LCG_SERVER_SSL_KEY_FILE", ""), + Port: getEnv("LCG_SERVER_PORT", "8080"), + Host: getEnv("LCG_SERVER_HOST", "localhost"), + ConfigFolder: getEnv("LCG_CONFIG_FOLDER", path.Join(homedir, ".config", "lcg", "config")), + AllowHTTP: getServerAllowHTTP(), + SSLCertFile: getEnv("LCG_SERVER_SSL_CERT_FILE", ""), + SSLKeyFile: getEnv("LCG_SERVER_SSL_KEY_FILE", ""), + RequireAuth: isServerRequireAuth(), + Password: getEnv("LCG_SERVER_PASSWORD", "admin#123456"), + Domain: getEnv("LCG_DOMAIN", getEnv("LCG_SERVER_HOST", "localhost")), + CookieSecure: isCookieSecure(), + CookiePath: getEnv("LCG_COOKIE_PATH", "/lcg"), + CookieTTLHours: getEnvInt("LCG_COOKIE_TTL_HOURS", 168), // 7 дней по умолчанию + BasePath: getEnv("LCG_BASE_URL", "/lcg"), + HealthUrl: getEnv("LCG_HEALTH_URL", "/api/v1/protected/sberchat/health"), + ProxyUrl: getEnv("LCG_PROXY_URL", "/api/v1/protected/sberchat/chat"), }, Validation: ValidationConfig{ MaxSystemPromptLength: getEnvInt("LCG_MAX_SYSTEM_PROMPT_LENGTH", 2000), @@ -162,6 +186,24 @@ func isAllowExecutionEnabled() bool { return vLower == "1" || vLower == "true" } +func isServerRequireAuth() bool { + v := strings.TrimSpace(getEnv("LCG_SERVER_REQUIRE_AUTH", "")) + if v == "" { + return false + } + vLower := strings.ToLower(v) + return vLower == "1" || vLower == "true" +} + +func isCookieSecure() bool { + v := strings.TrimSpace(getEnv("LCG_COOKIE_SECURE", "")) + if v == "" { + return false + } + vLower := strings.ToLower(v) + return vLower == "1" || vLower == "true" +} + var AppConfig Config func init() { diff --git a/deploy/.goreleaser.yaml b/deploy/.goreleaser.yaml new file mode 100644 index 0000000..3fce9f5 --- /dev/null +++ b/deploy/.goreleaser.yaml @@ -0,0 +1,29 @@ +# Goreleaser configuration version 2 +version: 2 + +builds: + - id: lcg + binary: "lcg_{{ .Version }}" + goos: + - linux + goarch: + - amd64 + - arm64 + env: + - CGO_ENABLED=0 + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + - -X main.date={{.Date}} + main: . + dir: . + +archives: + - id: lcg + builds: + - lcg + format: binary + name_template: "{{ .Binary }}_{{ .Os }}_{{ .Arch }}" + files: + - "lcg_{{ .Version }}" diff --git a/deploy/0.create_sealed_secrets.example.sh b/deploy/0.create_sealed_secrets.example.sh new file mode 100644 index 0000000..12fad1f --- /dev/null +++ b/deploy/0.create_sealed_secrets.example.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +# https://dev.to/ashokan/sealed-secrets-the-secret-sauce-for-managing-secrets-2hg6 +# head -c 64 /dev/urandom | base64 -w 0 +export KUBECONFIG=/home/su/.kube/config_hlab + +kubectl create secret generic lcg-secrets -n lcg \ + --from-literal=LCG_SERVER_PASSWORDL= \ + --from-literal=LCG_CSRF_SECRET=\ + --from-literal=LCG_JWT_SECRET=\ + --from-literal=LCG_JWT_TOKEN=\ + --dry-run=client -o yaml | tee secret-cfg.yaml + +kubeseal --controller-name=sealed-secrets-controller --controller-namespace=kube-system -o yaml VERSION.txt +log "📝 Версия записана в VERSION.txt: $VERSION" + +log "🚀 Сборка бинарных файлов LCG с goreleaser..." + +# Проверяем наличие goreleaser +if ! command -v goreleaser &> /dev/null; then + error "goreleaser не найден. Установите goreleaser:" + echo " curl -sL https://github.com/goreleaser/goreleaser/releases/latest/download/goreleaser_Linux_x86_64.tar.gz | tar -xz -C /usr/local/bin goreleaser" + exit 1 +fi + +# Проверяем наличие Go +if ! command -v go &> /dev/null; then + error "Go не найден. Установите Go для сборки." + exit 1 +fi + +# Переходим в корневую директорию проекта +cd "$(dirname "$0")/.." + +log "📁 Рабочая директория: $(pwd)" +log "📁 Папка dist будет создана в: $(pwd)/dist" + +# Очищаем предыдущие сборки если нужно +# if [ "$CLEAN" = "true" ]; then +# log "🧹 Очистка предыдущих сборок..." +# rm -rf dist/ +# goreleaser clean +# fi + +# Проверяем наличие .goreleaser.yaml +if [ ! -f "deploy/.goreleaser.yaml" ]; then + error "Файл .goreleaser.yaml не найден в папке deploy/" + exit 1 +fi + +# Копируем конфигурацию goreleaser в корень проекта +log "📋 Копирование конфигурации goreleaser..." +cp deploy/.goreleaser.yaml .goreleaser.yaml + +# Устанавливаем переменные окружения для версии +export GORELEASER_CURRENT_TAG="$VERSION" + +# Собираем бинарные файлы +log "🏗️ Сборка бинарных файлов для всех платформ..." +goreleaser build --snapshot --clean + +# Проверяем результат +if [ -d "dist" ]; then + log "📊 Собранные бинарные файлы:" + find dist -name "lcg_*" -type f | while read -r binary; do + echo " $binary ($(stat -c%s "$binary") bytes, $(file "$binary" | cut -d: -f2))" + done + + success "🎉 Бинарные файлы успешно собраны!" + + # Показываем структуру dist/ + log "📁 Структура папки dist/:" + tree -h dist/ 2>/dev/null || find dist -type f | sort + +else + error "Папка dist/ не создана. Проверьте конфигурацию goreleaser." + exit 1 +fi + +# Очищаем временный файл конфигурации +rm -f .goreleaser.yaml + +success "🎉 Сборка бинарных файлов завершена!" + +# Показываем команды для Docker сборки +echo "" +log "📝 Следующие шаги:" +echo " cd deploy" +echo " docker buildx build --platform linux/amd64,linux/arm64 --tag your-registry.com/lcg:$VERSION --push ." +echo " # или используйте скрипт:" +echo " ./5.build-docker.sh your-registry.com/lcg $VERSION" diff --git a/deploy/4.pvc.yaml b/deploy/4.pvc.yaml new file mode 100644 index 0000000..66fc796 --- /dev/null +++ b/deploy/4.pvc.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: lcg-data + namespace: lcg +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi + storageClassName: nfs diff --git a/deploy/5.build-docker.sh b/deploy/5.build-docker.sh new file mode 100755 index 0000000..856f9a2 --- /dev/null +++ b/deploy/5.build-docker.sh @@ -0,0 +1,183 @@ +#!/bin/bash + +# 🐳 LCG Docker Build Script +# Скрипт для сборки Docker образа с предварительно собранными бинарными файлами + +set -e + +# Цвета для вывода +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Функция для вывода сообщений +log() { + echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" +} + +success() { + echo -e "${GREEN}✅ $1${NC}" +} + +warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +error() { + echo -e "${RED}❌ $1${NC}" +} + +# Параметры +REPOSITORY=${1:-"your-registry.com/lcg"} +VERSION=${2:-""} +PLATFORMS=${3:-"linux/amd64,linux/arm64"} + +if [ -z "$VERSION" ]; then + error "Версия не указана! Использование: $0 " + echo "Пример: $0 your-registry.com/lcg v1.0.0 " + exit 1 +fi + +log "🐳 Сборка Docker образа LCG..." + +# Проверяем наличие docker +if ! command -v docker &> /dev/null; then + error "Docker не найден. Установите Docker для сборки образов." + exit 1 +fi + +# Проверяем наличие docker buildx +if ! docker buildx version &> /dev/null; then + error "Docker Buildx не найден. Установите Docker Buildx для мультиплатформенной сборки." + exit 1 +fi + +# Проверяем наличие бинарных файлов в текущей директории (если запускаем из корня) +if [ ! -d "dist" ]; then + error "Папка dist/ не найдена. Сначала соберите бинарные файлы:" + echo " ./deploy/4.build-binaries.sh $VERSION" + exit 1 +fi + +# Проверяем наличие бинарных файлов для всех платформ +MISSING_BINARIES=() + +# Ищем бинарные файлы с версией в имени +AMD64_BINARY=$(find dist -name "*linux_amd64*" -type d | head -1) +echo "AMD64_BINARY: $AMD64_BINARY" +ARM64_BINARY=$(find dist -name "*linux_arm64*" -type d | head -1) +echo "ARM64_BINARY: $ARM64_BINARY" + +# Проверяем наличие бинарных файлов в найденных папках и соответствие версии +if [ -n "$AMD64_BINARY" ]; then + AMD64_FILE=$(find "$AMD64_BINARY" -name "lcg_*" -type f | head -1) + if [ -z "$AMD64_FILE" ]; then + AMD64_BINARY="" + else + # Извлекаем версию из имени файла + FILE_VERSION=$(basename "$AMD64_FILE" | sed 's/lcg_//' | sed 's/-SNAPSHOT.*//') + # Нормализуем версии для сравнения (убираем префикс 'v' если есть) + NORMALIZED_FILE_VERSION=$(echo "$FILE_VERSION" | sed 's/^v//') + NORMALIZED_VERSION=$(echo "$VERSION" | sed 's/^v//') + if [ "$NORMALIZED_FILE_VERSION" != "$NORMALIZED_VERSION" ]; then + error "Версия в имени бинарного файла ($FILE_VERSION) не совпадает с переданной версией ($VERSION)" + echo "Файл: $AMD64_FILE" + echo "Ожидаемая версия: $VERSION" + echo "Версия в файле: $FILE_VERSION" + exit 1 + fi + fi +fi + +if [ -n "$ARM64_BINARY" ]; then + ARM64_FILE=$(find "$ARM64_BINARY" -name "lcg_*" -type f | head -1) + if [ -z "$ARM64_FILE" ]; then + ARM64_BINARY="" + else + # Извлекаем версию из имени файла + FILE_VERSION=$(basename "$ARM64_FILE" | sed 's/lcg_//' | sed 's/-SNAPSHOT.*//') + # Нормализуем версии для сравнения (убираем префикс 'v' если есть) + NORMALIZED_FILE_VERSION=$(echo "$FILE_VERSION" | sed 's/^v//') + NORMALIZED_VERSION=$(echo "$VERSION" | sed 's/^v//') + if [ "$NORMALIZED_FILE_VERSION" != "$NORMALIZED_VERSION" ]; then + error "Версия в имени бинарного файла ($FILE_VERSION) не совпадает с переданной версией ($VERSION)" + echo "Файл: $ARM64_FILE" + echo "Ожидаемая версия: $VERSION" + echo "Версия в файле: $FILE_VERSION" + exit 1 + fi + fi +fi + +if [ -z "$AMD64_BINARY" ]; then + MISSING_BINARIES+=("linux/amd64") +fi +if [ -z "$ARM64_BINARY" ]; then + MISSING_BINARIES+=("linux/arm64") +fi + +if [ ${#MISSING_BINARIES[@]} -gt 0 ]; then + error "Отсутствуют бинарные файлы для платформ: ${MISSING_BINARIES[*]}" + echo "Сначала соберите бинарные файлы:" + echo " ./4.build-binaries.sh $VERSION" + exit 1 +fi + +# Показываем найденные файлы и их версии +log "📊 Найденные бинарные файлы:" +if [ -n "$AMD64_FILE" ]; then + echo " AMD64: $AMD64_FILE" +fi +if [ -n "$ARM64_FILE" ]; then + echo " ARM64: $ARM64_FILE" +fi + +# Создаем builder если не существует +log "🔧 Настройка Docker Buildx..." +docker buildx create --name lcg-builder --use 2>/dev/null || docker buildx use lcg-builder + +# Копируем бинарные файлы и файл версии в папку deploy +log "📋 Копирование бинарных файлов и файла версии..." +cp -r dist ./deploy/dist +cp VERSION.txt ./deploy/VERSION.txt 2>/dev/null || echo "dev" > ./deploy/VERSION.txt + +# Сборка для всех платформ +log "🏗️ Сборка образа для платформ: $PLATFORMS" +log "📦 Репозиторий: $REPOSITORY" +log "🏷️ Версия: $VERSION" + +# Сборка и push +docker buildx build \ + --platform "$PLATFORMS" \ + --tag "$REPOSITORY:$VERSION" \ + --tag "$REPOSITORY:latest" \ + --push \ + --file deploy/Dockerfile \ + deploy/ + +# Очищаем скопированные файлы +rm -rf ./deploy/dist + +success "🎉 Образ успешно собран и отправлен в репозиторий!" + +# Показываем информацию о собранном образе +log "📊 Информация о собранном образе:" +echo " Репозиторий: $REPOSITORY" +echo " Версия: $VERSION" +echo " Платформы: $PLATFORMS" +echo " Теги: $REPOSITORY:$VERSION, $REPOSITORY:latest" + +# Проверяем образы в репозитории +log "🔍 Проверка образов в репозитории..." +docker buildx imagetools inspect "$REPOSITORY:$VERSION" || warning "Не удалось проверить образ в репозитории" + +success "🎉 Сборка завершена успешно!" + +# Показываем команды для использования +echo "" +log "📝 Полезные команды:" +echo " docker pull $REPOSITORY:$VERSION" +echo " docker run -p 8080:8080 $REPOSITORY:$VERSION" +echo " docker buildx imagetools inspect $REPOSITORY:$VERSION" diff --git a/deploy/6.full-build.sh b/deploy/6.full-build.sh new file mode 100755 index 0000000..45bec62 --- /dev/null +++ b/deploy/6.full-build.sh @@ -0,0 +1,202 @@ +#!/bin/bash + +# 🚀 LCG Full Build Script +# Полный скрипт сборки: бинарные файлы + Docker образ + +set -e + +# Цвета для вывода +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Функция для вывода сообщений +log() { + echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" +} + +success() { + echo -e "${GREEN}✅ $1${NC}" +} + +warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +error() { + echo -e "${RED}❌ $1${NC}" +} + +# Параметры + +REPOSITORY=${1:-"kuznetcovay/lcg"} +VERSION=${2:-""} +PLATFORMS=${3:-"linux/amd64,linux/arm64"} + +if [ -z "$VERSION" ]; then + error "Версия не указана! Использование: $0 " + echo "Пример: $0 kuznetcovay/lcg v1.0.0 linux/amd64,linux/arm64" + exit 1 +fi + +# Записываем версию в файл VERSION.txt (в корневой директории проекта) +cd "$(dirname "$0")/.." +echo "$VERSION" > VERSION.txt +log "📝 Версия записана в VERSION.txt: $VERSION" + +log "🚀 Полная сборка LCG (бинарные файлы + Docker образ)..." + +# Этап 1: Сборка бинарных файлов +log "📦 Этап 1: Сборка бинарных файлов с goreleaser..." +./deploy/4.build-binaries.sh "$VERSION" + +if [ $? -ne 0 ]; then + error "Ошибка при сборке бинарных файлов" + exit 1 +fi + +success "✅ Бинарные файлы собраны успешно" + +# Этап 2: Сборка Docker образа +log "🐳 Этап 2: Сборка Docker образа..." +./deploy/5.build-docker.sh "$REPOSITORY" "$VERSION" "$PLATFORMS" + +if [ $? -ne 0 ]; then + error "Ошибка при сборке Docker образа" + exit 1 +fi + +success "✅ Docker образы собраны успешно" + +# Этап 3: Генерация deployment.yaml +log "📝 Этап 3: Генерация deployment.yaml..." +# Generate deployment.yaml with env substitution +export REPOSITORY=$REPOSITORY +export VERSION=$VERSION +export PLATFORMS=$PLATFORMS +export KUBECONFIG="${HOME}/.kube/config_hlab" && kubectx default + +if ! envsubst < deploy/1.configmap.yaml > kustomize/configmap.yaml; then + error "Ошибка при генерации deploy/1.configmap.yaml" + exit 1 +fi + +success "✅ kustomize/configmap.yaml сгенерирован успешно" + +if ! envsubst < deploy/deployment.tmpl.yaml > kustomize/deployment.yaml; then + error "Ошибка при генерации kustomize/deployment.yaml" + exit 1 +fi +success "✅ kustomize/deployment.yaml сгенерирован успешно" + +if ! envsubst < deploy/ingress-route.tmpl.yaml > kustomize/ingress-route.yaml; then + error "Ошибка при генерации kustomize/ingress-route.yaml" + exit 1 +fi +success "✅ kustomize/ingress-route.yaml сгенерирован успешно" + +if ! envsubst < deploy/service.tmpl.yaml > kustomize/service.yaml; then + error "Ошибка при генерации kustomize/service.yaml" + exit 1 +fi +success "✅ kustomize/service.yaml сгенерирован успешно" + +if ! envsubst < deploy/kustomization.tmpl.yaml > kustomize/kustomization.yaml; then + error "Ошибка при генерации kustomize/kustomization.yaml" + exit 1 +fi +success "✅ kustomize/kustomization.yaml сгенерирован успешно" + +# отключить reconciliation flux +if kubectl get kustomization lcg -n flux-system > /dev/null 2>&1; then + kubectl patch kustomization lcg -n flux-system --type=merge -p '{"spec":{"suspend":true}}' +else + echo "ℹ️ Kustomization 'lcg' does not exist in 'flux-system' namespace. Skipping suspend." +fi +sleep 5 + + +# зафиксировать изменения в текущей ветке, если она не main +current_branch=$(git rev-parse --abbrev-ref HEAD) +if [ "$current_branch" != "main" ]; then + log "🔧 Исправления в текущей ветке: $current_branch" + # считать, что изменения уже сделаны + git add . + git commit -m "Исправления в ветке $current_branch" +fi + +# переключиться на ветку main и слить с текущей веткой, если не находимся на main +if [ "$current_branch" != "main" ]; then + git checkout main + git merge --no-ff -m "Merged branch '$current_branch' into main while building $VERSION" "$current_branch" +fi + +# переключиться на ветку release и слить с веткой main +git checkout -b release +git merge --no-ff -m "Merged main into release while building $VERSION" main + +# если тег $VERSION существует, удалить его и принудительно запушить +tag_exists=$(git tag -l "$VERSION") +if [ "$tag_exists" ]; then + log "🗑️ Удаление существующего тега $VERSION" + git tag -d "$VERSION" + git push origin ":refs/tags/$VERSION" +fi + +# Create tag $VERSION and push to remote release branch and all tags +git tag "$VERSION" +git push origin release +git push origin --tags + +# Push main branch +git checkout main +git push origin main + + +# Включить reconciliation flux +if kubectl get kustomization lcg -n flux-system > /dev/null 2>&1; then + kubectl patch kustomization lcg -n flux-system --type=merge -p '{"spec":{"suspend":false}}' +else + echo "ℹ️ Kustomization 'lcg' does not exist in 'flux-system' namespace. Skipping suspend." +fi +echo "🔄 Flux will automatically deploy $VERSION version in ~4-6 minutes..." + +# Итоговая информация +echo "" +log "🎉 Полная сборка завершена успешно!" +echo "" +log "📊 Результат:" +echo " Репозиторий: $REPOSITORY" +echo " Версия: $VERSION" +echo " Платформы: $PLATFORMS" +echo " Теги: $REPOSITORY:$VERSION, $REPOSITORY:latest" +echo "" +echo "" +log "🔍 Информация о git коммитах:" +git_log=$(git log release -1 --pretty=format:"%H - %s") +echo "$git_log" +echo "" + + +log "📝 Команды для использования:" +echo " docker pull $REPOSITORY:$VERSION" +echo " docker run -p 8080:8080 $REPOSITORY:$VERSION" +echo " docker buildx imagetools inspect $REPOSITORY:$VERSION" +echo "" +log "🔍 Проверка образа:" +echo " docker run --rm $REPOSITORY:$VERSION /app/lcg --version" +echo "" +log "📝 Команды для использования:" +echo " kubectl apply -k kustomize" +echo " kubectl get pods" +echo " kubectl get services" +echo " kubectl get ingress" +echo " kubectl get hpa" +echo " kubectl get servicemonitor" +echo " kubectl get pods" +echo " kubectl get services" +echo " kubectl get ingress" +echo " kubectl get hpa" +echo " kubectl get servicemonitor" diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..96e0594 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,39 @@ +# Однофазный build для LCG с предварительно собранным бинарным файлом +FROM alpine:3.22.2 + +# Устанавливаем зависимости +RUN apk --no-cache add ca-certificates tzdata + +# Создаем пользователя +RUN adduser -D -s /bin/sh lcg + +# Создаем директории и файлы +RUN mkdir -p /app/data /app/config /home/lcg/.config/lcg/gpt_results /home/lcg/.config/lcg/gpt_sys_prompts && \ + echo '[]' > /home/lcg/.config/lcg/gpt_results/lcg_history.json && \ + chown -R lcg:lcg /app /home/lcg/.config + +# Копируем файл версии +COPY VERSION.txt /app/VERSION.txt + +# Копируем предварительно собранный бинарный файл +# Ищем папку с бинарным файлом для текущей архитектуры +COPY dist/lcg_linux_${TARGETARCH}*/lcg_* /app/lcg + +# Устанавливаем права +RUN chmod +x /app/lcg + +# Переключаемся на пользователя lcg +USER lcg + +# Устанавливаем рабочую директорию +WORKDIR /app + +# Открываем порт +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/login || exit 1 + +# Запускаем приложение +CMD ["./lcg", "serve", "-H", "0.0.0.0", "-p", "8080"] diff --git a/deploy/VERSION.txt b/deploy/VERSION.txt new file mode 100644 index 0000000..f3b15f3 --- /dev/null +++ b/deploy/VERSION.txt @@ -0,0 +1 @@ +v2.0.2 diff --git a/deploy/deployment.tmpl.yaml b/deploy/deployment.tmpl.yaml new file mode 100644 index 0000000..098704d --- /dev/null +++ b/deploy/deployment.tmpl.yaml @@ -0,0 +1,95 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lcg + namespace: lcg + labels: + app: lcg + version: ${VERSION} +spec: + replicas: 1 + selector: + matchLabels: + app: lcg + template: + metadata: + labels: + app: lcg + version: ${VERSION} + spec: + containers: + - name: lcg + image: ${REPOSITORY}:${VERSION} + imagePullPolicy: Always + ports: + - containerPort: 8080 + protocol: TCP + envFrom: + - configMapRef: + name: lcg-config + - secretRef: + name: lcg-secret + env: + # Pod information + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 250m + memory: 512Mi + volumeMounts: + - name: lcg-data + mountPath: /app/data + - name: lcg-config + mountPath: /app/config + readOnly: true + # Health checks + startupProbe: + httpGet: + path: /login + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 + failureThreshold: 30 + readinessProbe: + httpGet: + path: /login + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /login + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 60 + volumes: + - name: lcg-data + persistentVolumeClaim: + claimName: lcg-data + - name: lcg-config + configMap: + name: lcg-config + # Security context + securityContext: + runAsNonRoot: true + runAsUser: 1001 + readOnlyRootFilesystem: false + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + restartPolicy: Always diff --git a/deploy/hpa.yaml b/deploy/hpa.yaml new file mode 100644 index 0000000..5feeba6 --- /dev/null +++ b/deploy/hpa.yaml @@ -0,0 +1,42 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: lcg-hpa + namespace: lcg +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: lcg + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 + behavior: + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Percent + value: 10 + periodSeconds: 60 + scaleUp: + stabilizationWindowSeconds: 60 + policies: + - type: Percent + value: 50 + periodSeconds: 60 + - type: Pods + value: 2 + periodSeconds: 60 + selectPolicy: Max diff --git a/deploy/ingress-route.tmpl.yaml b/deploy/ingress-route.tmpl.yaml new file mode 100644 index 0000000..b9c8189 --- /dev/null +++ b/deploy/ingress-route.tmpl.yaml @@ -0,0 +1,64 @@ +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: lcg-route + namespace: lcg + labels: + app: lcg + version: ${VERSION} +spec: + entryPoints: + - websecure + routes: + - kind: Rule + match: Host(`direct-dev.ru`) && PathPrefix(`/lcg`) + services: + - name: lcg + port: 8080 + tls: + secretName: le-root-direct-dev-ru +--- + +# apiVersion: traefik.io/v1alpha1 +# kind: IngressRoute +# metadata: +# name: lcg-route +# namespace: lcg +# spec: +# entryPoints: +# - websecure +# routes: +# - kind: Rule +# match: Host(`direct-dev.ru`) && PathPrefix(`/lcg`) +# services: +# - name: lcg +# port: 8080 +# middlewares: +# - name: lcg-strip-prefix +# tls: +# secretName: le-root-direct-dev-ru +# --- +# apiVersion: traefik.io/v1alpha1 +# kind: Middleware +# metadata: +# name: lcg-strip-prefix +# namespace: lcg +# spec: +# stripPrefix: +# prefixes: +# - /lcg +# --- +# apiVersion: traefik.io/v1alpha1 +# kind: Middleware +# metadata: +# name: lcg-headers +# namespace: lcg +# spec: +# headers: +# customRequestHeaders: +# X-Forwarded-Proto: "https" +# X-Forwarded-Port: "443" +# customResponseHeaders: +# X-Frame-Options: "DENY" +# X-Content-Type-Options: "nosniff" +# X-XSS-Protection: "1; mode=block" diff --git a/deploy/kustomization.tmpl.yaml b/deploy/kustomization.tmpl.yaml new file mode 100644 index 0000000..3d43c1d --- /dev/null +++ b/deploy/kustomization.tmpl.yaml @@ -0,0 +1,25 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# Namespace +namespace: lcg + +# Resources +resources: + - configmap.yaml + - secret.yaml + - deployment.yaml + - service.yaml + - ingress-route.yaml + +# Common labels +commonLabels: + app: lcg + version: ${VERSION} + managed-by: kustomize + +# Images +images: + - name: lcg + newName: ${REPOSITORY} + newTag: ${VERSION} diff --git a/deploy/service.tmpl.yaml b/deploy/service.tmpl.yaml new file mode 100644 index 0000000..72bcad4 --- /dev/null +++ b/deploy/service.tmpl.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: lcg + namespace: lcg + labels: + app: lcg + version: ${VERSION} +spec: + type: ClusterIP + ports: + - port: 8080 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: lcg + version: ${VERSION} diff --git a/API_CONTRACT.md b/docs/API_CONTRACT.md similarity index 100% rename from API_CONTRACT.md rename to docs/API_CONTRACT.md diff --git a/API_GUIDE.md b/docs/API_GUIDE.md similarity index 100% rename from API_GUIDE.md rename to docs/API_GUIDE.md diff --git a/CONFIG_COMMAND.md b/docs/CONFIG_COMMAND.md similarity index 100% rename from CONFIG_COMMAND.md rename to docs/CONFIG_COMMAND.md diff --git a/docs/CSRF_TESTING_GUIDE.md b/docs/CSRF_TESTING_GUIDE.md new file mode 100644 index 0000000..85fcc2f --- /dev/null +++ b/docs/CSRF_TESTING_GUIDE.md @@ -0,0 +1,231 @@ +# 🛡️ Руководство по тестированию CSRF защиты + +## 📋 Обзор + +Это руководство поможет вам протестировать CSRF защиту в LCG приложении и понять, как работают CSRF атаки. + +## 🚀 Быстрый старт + +### 1. Запуск сервера с CSRF защитой + +```bash +# Запуск с аутентификацией и CSRF защитой +LCG_SERVER_REQUIRE_AUTH=true ./lcg serve -p 8080 +``` + +### 2. Автоматическое тестирование + +```bash +# Запуск автоматических тестов +./test_csrf.sh +``` + +### 3. Ручное тестирование + +```bash +# Откройте в браузере +open csrf_test.html +``` + +## 🧪 Типы тестов + +### ✅ **Тест 1: Защищенные запросы** + +- **Цель**: Проверить, что POST запросы без CSRF токена блокируются +- **Ожидаемый результат**: 403 Forbidden +- **Endpoints**: `/api/execute`, `/api/save-result`, `/api/add-to-history` + +### ✅ **Тест 2: Разрешенные запросы** + +- **Цель**: Проверить, что GET запросы работают +- **Ожидаемый результат**: 200 OK +- **Endpoints**: `/login`, `/`, `/history` + +### ✅ **Тест 3: CSRF токены** + +- **Цель**: Проверить наличие CSRF токенов в формах +- **Ожидаемый результат**: Токены присутствуют в HTML + +### ✅ **Тест 4: Поддельные токены** + +- **Цель**: Проверить защиту от поддельных токенов +- **Ожидаемый результат**: 403 Forbidden + +## 🎯 Сценарии атак + +### **Сценарий 1: Выполнение команд** + +```html + +
+ + + +
+``` + +### **Сценарий 2: Сохранение данных** + +```html + +
+ + + +
+``` + +### **Сценарий 3: JavaScript атака** + +```javascript +// Злонамеренный JavaScript +fetch('http://localhost:8080/api/execute', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({prompt: 'whoami', system_id: '1'}) +}); +``` + +## 🔍 Анализ результатов + +### **✅ Защита работает, если:** + +- Все POST запросы возвращают 403 Forbidden +- В ответах есть "CSRF token required" +- GET запросы работают нормально +- CSRF токены присутствуют в формах + +### **❌ Уязвимость есть, если:** + +- POST запросы выполняются успешно (200 OK) +- Команды выполняются на сервере +- Данные сохраняются без CSRF токенов +- Нет проверки Origin/Referer заголовков + +## 🛠️ Инструменты тестирования + +### **1. Автоматический скрипт** + +```bash +./test_csrf.sh +``` + +- Тестирует все основные endpoints +- Проверяет CSRF токены +- Выводит подробный отчет + +### **2. HTML тестовая страница** + +```bash +open csrf_test.html +``` + +- Интерактивное тестирование +- Визуальная проверка результатов +- Тестирование в браузере + +### **3. Демонстрационная атака** + +```bash +open csrf_demo.html +``` + +- Показывает, как работают CSRF атаки +- Демонстрирует уязвимости +- Образовательные цели + +## 🔧 Настройка тестов + +### **Переменные окружения для тестирования:** + +```bash +# Включить аутентификацию +LCG_SERVER_REQUIRE_AUTH=true + +# Настроить CSRF защиту +LCG_COOKIE_SECURE=false +LCG_DOMAIN=.localhost +LCG_COOKIE_PATH=/ + +# Запуск сервера +./lcg serve -H 0.0.0.0 -p 8080 +``` + +### **Настройка reverse proxy для тестирования:** + +```bash +# Для тестирования за reverse proxy +LCG_SERVER_REQUIRE_AUTH=true \ +LCG_SERVER_ALLOW_HTTP=true \ +LCG_DOMAIN=.yourdomain.com \ +LCG_COOKIE_PATH=/lcg \ +LCG_COOKIE_SECURE=false \ +./lcg serve -H 0.0.0.0 -p 8080 +``` + +## 📊 Интерпретация результатов + +### **Успешные тесты:** + +``` text +✅ CSRF защита /api/execute: PASS - Запрос заблокирован (403 Forbidden) +✅ CSRF защита /api/save-result: PASS - Запрос заблокирован (403 Forbidden) +✅ CSRF защита /api/add-to-history: PASS - Запрос заблокирован (403 Forbidden) +✅ GET запросы: PASS - GET запросы работают (HTTP 200) +✅ CSRF токен на странице входа: PASS - Токен найден +✅ CSRF защита от поддельного токена: PASS - Поддельный токен заблокирован (403 Forbidden) +``` + +### **Проблемные тесты:** + +``` text +❌ CSRF защита /api/execute: FAIL - Запрос прошел (HTTP 200) +❌ CSRF защита /api/save-result: FAIL - Запрос прошел (HTTP 200) +❌ CSRF токен на странице входа: FAIL - Токен не найден +``` + +## 🚨 Частые проблемы + +### **1. Cookies не работают** + +- Проверьте настройки `LCG_DOMAIN` +- Убедитесь, что `LCG_COOKIE_PATH` правильный +- Проверьте настройки reverse proxy + +### **2. CSRF токены не генерируются** + +- Убедитесь, что `LCG_SERVER_REQUIRE_AUTH=true` +- Проверьте инициализацию CSRF менеджера +- Проверьте логи сервера + +### **3. Запросы проходят без токенов** + +- Проверьте middleware в `serve/middleware.go` +- Убедитесь, что CSRF middleware применяется +- Проверьте исключения в middleware + +## 📝 Рекомендации + +### **Для разработчиков:** + +1. Всегда тестируйте CSRF защиту +2. Используйте автоматические тесты +3. Проверяйте все POST endpoints +4. Валидируйте CSRF токены + +### **Для администраторов:** + +1. Регулярно запускайте тесты +2. Мониторьте логи на подозрительную активность +3. Настройте правильные заголовки в reverse proxy +4. Используйте HTTPS в продакшене + +## 🎓 Образовательные материалы + +- **OWASP CSRF Prevention Cheat Sheet**: +- **CSRF атаки**: +- **SameSite cookies**: + +--- + +**⚠️ ВНИМАНИЕ**: Эти тесты предназначены только для проверки безопасности вашего собственного приложения. Не используйте их для атак на чужие системы! diff --git a/README.md b/docs/README.md similarity index 100% rename from README.md rename to docs/README.md diff --git a/RELEASE_GUIDE.md b/docs/RELEASE_GUIDE.md similarity index 100% rename from RELEASE_GUIDE.md rename to docs/RELEASE_GUIDE.md diff --git a/docs/REVERSE_PROXY_GUIDE.md b/docs/REVERSE_PROXY_GUIDE.md new file mode 100644 index 0000000..8a03033 --- /dev/null +++ b/docs/REVERSE_PROXY_GUIDE.md @@ -0,0 +1,258 @@ +# 🔄 Гайд по настройке LCG за Reverse Proxy + +## 📋 Переменные окружения для Reverse Proxy + +### 🔧 **Основные настройки:** + +```bash +# Включить аутентификацию +LCG_SERVER_REQUIRE_AUTH=true + +# Настроить домен для cookies (опционально) +LCG_DOMAIN=.yourdomain.com + +# Настроить путь для cookies (для префикса пути) +LCG_COOKIE_PATH=/lcg + +# Управление Secure флагом cookies +LCG_COOKIE_SECURE=false + +# Разрешить HTTP (для работы за reverse proxy) +LCG_SERVER_ALLOW_HTTP=true + +# Настроить хост и порт +LCG_SERVER_HOST=0.0.0.0 +LCG_SERVER_PORT=8080 + +# Пароль для входа (по умолчанию: admin#123456) +LCG_SERVER_PASSWORD=your_secure_password +``` + +## 🚀 **Запуск за Reverse Proxy** + +### **1. Nginx конфигурация:** + +```nginx +server { + listen 443 ssl; + server_name yourdomain.com; + + # SSL настройки + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Важно для работы cookies + proxy_cookie_domain localhost yourdomain.com; + } +} +``` + +### **2. Apache конфигурация:** + +```apache + + ServerName yourdomain.com + SSLEngine on + SSLCertificateFile /path/to/cert.pem + SSLCertificateKeyFile /path/to/key.pem + + ProxyPreserveHost On + ProxyPass / http://localhost:8080/ + ProxyPassReverse / http://localhost:8080/ + + # Настройки для cookies + ProxyPassReverseCookieDomain localhost yourdomain.com + +``` + +### **3. Caddy конфигурация:** + +```caddy +yourdomain.com { + reverse_proxy localhost:8080 { + header_up Host {host} + header_up X-Real-IP {remote} + header_up X-Forwarded-For {remote} + header_up X-Forwarded-Proto {scheme} + } +} +``` + +## 🏃‍♂️ **Команды запуска** + +### **Базовый запуск:** + +```bash +LCG_SERVER_REQUIRE_AUTH=true LCG_SERVER_ALLOW_HTTP=true ./lcg serve -H 0.0.0.0 -p 8080 +``` + +### **С настройкой домена:** + +```bash +LCG_SERVER_REQUIRE_AUTH=true \ +LCG_SERVER_ALLOW_HTTP=true \ +LCG_DOMAIN=.yourdomain.com \ +LCG_COOKIE_SECURE=false \ +./lcg serve -H 0.0.0.0 -p 8080 +``` + +### **С кастомным паролем:** + +```bash +LCG_SERVER_REQUIRE_AUTH=true \ +LCG_SERVER_ALLOW_HTTP=true \ +LCG_SERVER_PASSWORD=my_secure_password \ +LCG_DOMAIN=.yourdomain.com \ +./lcg serve -H 0.0.0.0 -p 8080 +``` + +## 🔒 **Безопасность** + +### **Рекомендуемые настройки:** + +- ✅ `LCG_SERVER_REQUIRE_AUTH=true` - всегда включайте аутентификацию +- ✅ `LCG_COOKIE_SECURE=false` - для HTTP за reverse proxy +- ✅ `LCG_DOMAIN=.yourdomain.com` - для правильной работы cookies +- ✅ Сильный пароль в `LCG_SERVER_PASSWORD` + +### **Настройки Reverse Proxy:** + +- ✅ Передавайте заголовки `X-Forwarded-*` +- ✅ Настройте `proxy_cookie_domain` в Nginx +- ✅ Используйте HTTPS на уровне reverse proxy + +## 🐳 **Docker Compose пример** + +```yaml +version: '3.8' +services: + lcg: + image: your-lcg-image + environment: + - LCG_SERVER_REQUIRE_AUTH=true + - LCG_SERVER_ALLOW_HTTP=true + - LCG_DOMAIN=.yourdomain.com + - LCG_COOKIE_SECURE=false + - LCG_SERVER_PASSWORD=secure_password + ports: + - "8080:8080" + restart: unless-stopped + + nginx: + image: nginx:alpine + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf + - ./ssl:/etc/nginx/ssl + ports: + - "443:443" + depends_on: + - lcg +``` + +## 🔍 **Диагностика проблем** + +### **Проверка cookies:** + +```bash +# Проверить установку cookies +curl -I https://yourdomain.com/login + +# Проверить домен cookies +curl -v https://yourdomain.com/login 2>&1 | grep -i cookie +``` + +### **Логи приложения:** + +```bash +# Запуск с debug режимом +LCG_SERVER_REQUIRE_AUTH=true \ +LCG_SERVER_ALLOW_HTTP=true \ +./lcg -d serve -H 0.0.0.0 -p 8080 +``` + +## 📝 **Примечания** + +- **SameSite=Lax** - более мягкий режим для reverse proxy +- **Domain cookies** - работают только с указанным доменом +- **Secure=false** - обязательно для HTTP за reverse proxy +- **X-Forwarded-* заголовки** - важны для правильной работы + +## 🆘 **Частые проблемы** + +1. **Cookies не работают** → Проверьте `LCG_DOMAIN` и настройки reverse proxy +2. **Ошибка 403 CSRF** → Проверьте передачу cookies через reverse proxy +3. **Не работает аутентификация** → Убедитесь что `LCG_SERVER_REQUIRE_AUTH=true` +4. **Проблемы с HTTPS** → Настройте `LCG_COOKIE_SECURE=false` для HTTP за reverse proxy + +## 🛣️ **Конфигурация с префиксом пути** + +### **Пример: example.com/lcg** + +#### **Переменные окружения для префикса:** + +```bash +LCG_SERVER_REQUIRE_AUTH=true \ +LCG_SERVER_ALLOW_HTTP=true \ +LCG_DOMAIN=.example.com \ +LCG_COOKIE_PATH=/lcg \ +LCG_COOKIE_SECURE=false \ +./lcg serve -H 0.0.0.0 -p 8080 +``` + +#### **Nginx с префиксом:** + +```nginx +server { + listen 443 ssl; + server_name example.com; + + location /lcg/ { + proxy_pass http://localhost:8080/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Важно для работы cookies с префиксом + proxy_cookie_domain localhost example.com; + proxy_cookie_path / /lcg/; + } +} +``` + +#### **Apache с префиксом:** + +```apache + + ServerName example.com + SSLEngine on + + ProxyPreserveHost On + ProxyPass /lcg/ http://localhost:8080/ + ProxyPassReverse /lcg/ http://localhost:8080/ + + # Настройки для cookies с префиксом + ProxyPassReverseCookieDomain localhost example.com + ProxyPassReverseCookiePath / /lcg/ + +``` + +#### **Caddy с префиксом:** + +```caddy +example.com { + reverse_proxy /lcg/* localhost:8080 { + header_up Host {host} + header_up X-Real-IP {remote} + header_up X-Forwarded-For {remote} + header_up X-Forwarded-Proto {scheme} + } +} +``` diff --git a/ROADMAP.md b/docs/ROADMAP.md similarity index 100% rename from ROADMAP.md rename to docs/ROADMAP.md diff --git a/SECURITY_FEATURES.md b/docs/SECURITY_FEATURES.md similarity index 100% rename from SECURITY_FEATURES.md rename to docs/SECURITY_FEATURES.md diff --git a/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md similarity index 68% rename from USAGE_GUIDE.md rename to docs/USAGE_GUIDE.md index ad29bfe..4c74dc8 100644 --- a/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -2,7 +2,7 @@ ## Что это -Linux Command GPT (`lcg`) преобразует описание на естественном языке в готовую Linux‑команду. Инструмент поддерживает сменные провайдеры LLM (Ollama или Proxy), управление системными промптами, историю запросов, сохранение результатов, HTTP сервер для просмотра результатов и интерактивные действия над сгенерированной командой. +Linux Command GPT (`lcg`) преобразует описание на естественном языке в готовую команду для Linux или Windows. Инструмент автоматически определяет операционную систему и использует соответствующие промпты. Поддерживает сменные провайдеры LLM (Ollama или Proxy), управление системными промптами, историю запросов, сохранение результатов, HTTP сервер для просмотра результатов, аутентификацию, CSRF защиту, интерактивные действия над сгенерированной командой и деплой в Kubernetes. ## Требования @@ -60,11 +60,18 @@ lcg --file /path/to/context.txt "хочу вывести список дирек Действия: (c)копировать, (s)сохранить, (r)перегенерировать, (e)выполнить, (v|vv|vvv)подробно, (n)ничего: ``` -### Что нового в 2.0.1 +### Что нового в 3.0.0 -- Улучшена мобильная версия веб‑интерфейса: корректные размеры кнопок, шрифтов и отступов; адаптивная верстка -- Учитывается `prefers-reduced-motion` для снижения анимаций, если это задано в системе -- Добавлен REST эндпоинт `POST /execute` (только через curl) — см. подробности и примеры в `API_GUIDE.md` +- **Аутентификация**: Добавлена система аутентификации с JWT токенами и HTTP-only cookies +- **CSRF защита**: Полная защита от CSRF атак с токенами и middleware +- **Безопасность**: Улучшенная безопасность с проверкой токенов и сессий +- **Kubernetes деплой**: Полный набор манифестов для деплоя в Kubernetes с Traefik +- **Flux CD**: GitOps конфигурация для автоматического деплоя +- **Reverse Proxy**: Поддержка работы за reverse proxy с настройкой cookies +- **Веб-интерфейс**: Улучшенный веб-интерфейс с современным дизайном +- **Мониторинг**: Prometheus метрики и ServiceMonitor +- **Масштабирование**: HPA для автоматического масштабирования +- **Тестирование**: Инструменты для тестирования CSRF защиты ## Переменные окружения @@ -90,6 +97,13 @@ lcg --file /path/to/context.txt "хочу вывести список дирек | `LCG_ALLOW_EXECUTION` | пусто | Если `1`/`true` — включает возможность выполнения команд через опцию `(e)` в меню действий. | | `LCG_SERVER_PORT` | `8080` | Порт для HTTP сервера просмотра результатов. | | `LCG_SERVER_HOST` | `localhost` | Хост для HTTP сервера просмотра результатов. | +| `LCG_SERVER_REQUIRE_AUTH` | `false` | Требовать аутентификацию для доступа к веб-интерфейсу. | +| `LCG_SERVER_PASSWORD` | `admin#123456` | Пароль для аутентификации. | +| `LCG_COOKIE_SECURE` | `false` | Использовать Secure флаг для cookies (для HTTPS). | +| `LCG_DOMAIN` | пусто | Домен для cookies (для reverse proxy). | +| `LCG_COOKIE_PATH` | `/` | Путь для cookies (для reverse proxy). | +| `LCG_COOKIE_TTL_HOURS` | `168` | Время жизни cookies в часах (по умолчанию 7 дней). | +| `LCG_CSRF_SECRET` | пусто | Секрет для CSRF токенов (генерируется автоматически). | Примеры настройки: @@ -104,6 +118,14 @@ export LCG_PROVIDER=proxy export LCG_HOST=http://localhost:8080 export LCG_MODEL=GigaChat-2 export LCG_JWT_TOKEN=your_jwt_token_here + +# Аутентификация и безопасность +export LCG_SERVER_REQUIRE_AUTH=true +export LCG_SERVER_PASSWORD=my_secure_password +export LCG_COOKIE_SECURE=false +export LCG_DOMAIN=.example.com +export LCG_COOKIE_PATH=/lcg +export LCG_COOKIE_TTL_HOURS=72 # 3 дня ``` ## Базовый синтаксис @@ -146,6 +168,8 @@ lcg [глобальные опции] <описание команды> - `--port, -p` — порт сервера (по умолчанию из `LCG_SERVER_PORT`) - `--host, -H` — хост сервера (по умолчанию из `LCG_SERVER_HOST`) - `--browser, -b` — открыть браузер автоматически после старта + - `--require-auth` — включить аутентификацию (переопределяет `LCG_SERVER_REQUIRE_AUTH`) + - `--password` — пароль для аутентификации (переопределяет `LCG_SERVER_PASSWORD`) ### Подробные объяснения (v/vv/vvv) @@ -196,6 +220,39 @@ lcg [глобальные опции] <описание команды> - Не вставляйте секреты в запросы. Перед выполнением (`e`) проверяйте команду вручную. - Для структуры API см. `API_CONTRACT.md` (эндпоинты и форматы запросов/ответов). +## Поддержка операционных систем + +### Автоматическое определение ОС + +Приложение автоматически определяет операционную систему и использует соответствующие промпты: + +- **Linux/Unix системы** (включая macOS): используются промпты для Linux команд +- **Windows**: используются промпты для Windows команд (PowerShell, CMD, Batch) + +### Промпты для Windows + +На Windows системах доступны следующие встроенные промпты: + +| ID | Name | Описание | +| --- | --- | --- | +| 1 | windows-command | Основной промпт для генерации Windows команд | +| 2 | windows-command-with-explanation | Промпт с подробным объяснением команд | +| 3 | windows-command-safe | Безопасный анализ команд с предупреждениями | +| 4 | windows-command-verbose | Подробный анализ с техническими деталями | +| 5 | windows-command-simple | Простое и понятное объяснение | + +### Примеры использования на Windows + +```cmd +# PowerShell команды +lcg "хочу получить список всех процессов" +lcg "показать информацию о дисках" + +# CMD команды +lcg "создать папку test и перейти в неё" +lcg "найти все файлы .txt в текущей директории" +``` + ## Системные промпты ### Управление промптами @@ -210,6 +267,9 @@ lcg [глобальные опции] <описание команды> ### Встроенные промпты (ID 1–5) +Промпты автоматически выбираются в зависимости от операционной системы: + +**Linux/Unix системы:** | ID | Name | Описание | | --- | --- | --- | | 1 | linux-command | «Ответь только Linux‑командой, без форматирования и объяснений». | @@ -218,6 +278,15 @@ lcg [глобальные опции] <описание команды> | 4 | linux-command-verbose | Команда с подробными объяснениями флагов и альтернатив. | | 5 | linux-command-simple | Простые команды, избегать сложных опций. | +**Windows системы:** +| ID | Name | Описание | +| --- | --- | --- | +| 1 | windows-command | «Ответь только Windows‑командой, без форматирования и объяснений». | +| 2 | windows-command-with-explanation | Сгенерируй команду и кратко объясни, что она делает (формат: COMMAND: explanation). | +| 3 | windows-command-safe | Безопасные команды (без потери данных). Вывод — только команда. | +| 4 | windows-command-verbose | Команда с подробными объяснениями флагов и альтернатив. | +| 5 | windows-command-simple | Простые команды, избегать сложных опций. | + ### Промпты подробности (ID 6–8) | ID | Name | Описание | @@ -275,6 +344,12 @@ lcg serve - **Современный дизайн** — адаптивный интерфейс с карточками файлов - **Сортировка** — файлы отсортированы по дате изменения (новые сверху) - **Превью содержимого** — первые 200 символов каждого файла +- **Аутентификация** — защищенный доступ с JWT токенами +- **CSRF защита** — защита от межсайтовых атак +- **История запросов** (`/history`) — просмотр истории всех запросов +- **Управление промптами** (`/prompts`) — редактирование системных промптов +- **Выполнение команд** (`/run`) — интерактивное выполнение команд +- **Безопасность** — HTTP-only cookies, проверка токенов Структура файла (команда): @@ -358,6 +433,14 @@ lcg serve --port 9090 # Запуск на всех интерфейсах lcg serve --host 0.0.0.0 --port 8080 + +# Запуск с аутентификацией +lcg serve --require-auth --password my_secure_password + +# Запуск с переменными окружения +export LCG_SERVER_REQUIRE_AUTH=true +export LCG_SERVER_PASSWORD=admin#123456 +lcg serve ``` ## История @@ -377,6 +460,10 @@ lcg history list - Для `ollama`/`proxy` API‑ключ не нужен; команды `update-key`/`delete-key` просто уведомят об этом. - HTTP сервер не запускается: проверьте, что порт свободен, используйте `--port` для смены порта. - Веб-интерфейс не отображает файлы: убедитесь, что в `LCG_RESULT_FOLDER` есть `.md` файлы. +- **Аутентификация не работает**: проверьте `LCG_SERVER_REQUIRE_AUTH=true` и правильность пароля. +- **CSRF ошибки**: убедитесь, что токены передаются в заголовках `X-CSRF-Token`. +- **Cookies не сохраняются**: проверьте настройки `LCG_DOMAIN` и `LCG_COOKIE_PATH` для reverse proxy. +- **Kubernetes деплой не работает**: проверьте права доступа к кластеру и наличие всех манифестов. ## JSON‑история запросов @@ -408,17 +495,148 @@ lcg history list ## Доступ к локальному API -Эндпоинт: `POST /execute` (только через curl). +### Основные эндпоинты + +- `POST /api/execute` — выполнение запросов к LLM +- `POST /api/save-result` — сохранение результатов +- `POST /api/add-to-history` — добавление в историю +- `GET /api/login` — страница аутентификации +- `POST /api/login` — аутентификация +- `POST /api/logout` — выход из системы +- `GET /metrics` — Prometheus метрики + +### Примеры использования ```bash # Запустить сервер lcg serve -# Выполнить запрос -curl -X POST http://localhost:8080/execute \ +# Выполнить запрос (без аутентификации) +curl -X POST http://localhost:8080/api/execute \ -H "Content-Type: application/json" \ -A curl \ -d '{"prompt": "create directory test", "verbose": "vv"}' + +# Аутентификация +curl -X POST http://localhost:8080/api/login \ + -H "Content-Type: application/json" \ + -d '{"username": "admin", "password": "admin#123456"}' + +# Выполнение с CSRF токеном +curl -X POST http://localhost:8080/api/execute \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: your_csrf_token" \ + -H "Cookie: auth_token=your_jwt_token" \ + -d '{"prompt": "create directory test"}' ``` Подробности и примеры: `API_GUIDE.md`. + +## Kubernetes деплой + +### Быстрый деплой + +```bash +# Переход в папку деплоя +cd deploy + +# Полный деплой (сборка + деплой + проверка) +./full-deploy.sh + +# Или поэтапно +./build.sh lcg latest +./deploy.sh +./health-check.sh +``` + +### Использование Make + +```bash +# Справка +make help + +# Сборка и деплой +make build +make deploy + +# Мониторинг +make status +make logs +make monitor + +# Удаление +make undeploy +``` + +### Flux CD (GitOps) + +```bash +# Настройка Flux CD +cd deploy/flux +./setup-flux.sh + +# Создание Kustomization +./create_kustomization.sh + +# Мониторинг +kubectl get kustomization lcg -n flux-system +``` + +### Конфигурация для reverse proxy + +```bash +# Настройка для работы за reverse proxy +export LCG_SERVER_REQUIRE_AUTH=true +export LCG_SERVER_ALLOW_HTTP=true +export LCG_DOMAIN=.example.com +export LCG_COOKIE_PATH=/lcg +export LCG_COOKIE_SECURE=false + +# Запуск +./lcg serve -H 0.0.0.0 -p 8080 +``` + +### Мониторинг и безопасность + +- **Prometheus метрики**: `/metrics` endpoint +- **Health checks**: автоматические проверки готовности +- **HPA**: автоматическое масштабирование (2-10 replicas) +- **CSRF защита**: токены для всех POST запросов +- **Аутентификация**: JWT токены в HTTP-only cookies +- **Security context**: non-root пользователь, минимальные права + +Подробности: `deploy/README.md` и `deploy/flux/README.md`. + +## Тестирование CSRF защиты + +### Автоматическое тестирование + +```bash +# Запуск тестов CSRF защиты +./test_csrf.sh + +# Проверка результатов +echo "Проверьте вывод на наличие ошибок 403 Forbidden" +``` + +### Ручное тестирование + +```bash +# Откройте csrf_test.html в браузере +open csrf_test.html + +# Или используйте curl для тестирования +curl -X POST http://localhost:8080/api/execute \ + -H "Content-Type: application/json" \ + -d '{"prompt": "test"}' \ + -v +``` + +### Демонстрация уязвимости + +```bash +# Откройте csrf_demo.html для демонстрации атаки +open csrf_demo.html +``` + +Подробности: `CSRF_TESTING_GUIDE.md`. diff --git a/VALIDATION_CONFIG.md b/docs/VALIDATION_CONFIG.md similarity index 100% rename from VALIDATION_CONFIG.md rename to docs/VALIDATION_CONFIG.md diff --git a/VERBOSE_PROMPT_EDITING.md b/docs/VERBOSE_PROMPT_EDITING.md similarity index 100% rename from VERBOSE_PROMPT_EDITING.md rename to docs/VERBOSE_PROMPT_EDITING.md diff --git a/go.mod b/go.mod index 9635340..2efe532 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,15 @@ module github.com/direct-dev-ru/linux-command-gpt -go 1.18 +go 1.21 + +toolchain go1.23.4 require github.com/atotto/clipboard v0.1.4 require gopkg.in/yaml.v3 v3.0.1 +require github.com/golang-jwt/jwt/v5 v5.3.0 + require ( github.com/cpuguy83/go-md2man/v2 v2.0.5 //indirect github.com/russross/blackfriday/v2 v2.1.0 diff --git a/go.sum b/go.sum index f2f0aa3..1096238 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= diff --git a/gpt/builtin_prompts.go b/gpt/builtin_prompts.go index 989c2f6..effdaa0 100644 --- a/gpt/builtin_prompts.go +++ b/gpt/builtin_prompts.go @@ -2,6 +2,7 @@ package gpt import ( _ "embed" + "runtime" "gopkg.in/yaml.v3" ) @@ -9,6 +10,9 @@ import ( //go:embed builtin_prompts.yaml var builtinPromptsYAML string +//go:embed builtin_prompts_windows.yaml +var builtinPromptsWindowsYAML string + var builtinPrompts string // BuiltinPromptsData структура для YAML файла @@ -117,7 +121,12 @@ func GetBuiltinPromptByIDAndLanguage(id int, lang string) *SystemPrompt { func InitBuiltinPrompts(embeddedBuiltinPromptsYAML string) { // Используем встроенный YAML, если переданный параметр пустой if embeddedBuiltinPromptsYAML == "" { - builtinPrompts = builtinPromptsYAML + // Выбираем промпты в зависимости от операционной системы + if runtime.GOOS == "windows" { + builtinPrompts = builtinPromptsWindowsYAML + } else { + builtinPrompts = builtinPromptsYAML + } } else { builtinPrompts = embeddedBuiltinPromptsYAML } diff --git a/gpt/builtin_prompts_windows.yaml b/gpt/builtin_prompts_windows.yaml new file mode 100644 index 0000000..67ee9c0 --- /dev/null +++ b/gpt/builtin_prompts_windows.yaml @@ -0,0 +1,262 @@ +prompts: + - id: 1 + name: "windows-command" + description: + en: "Main prompt for generating Windows commands" + ru: "Основной промпт для генерации Windows команд" + content: + en: | + You are a Windows command line expert. + Analyze the user's task, given in natural language, and suggest + a Windows command (PowerShell, CMD, or batch) that will help accomplish this task, and provide a detailed explanation of what it does, + its parameters and possible use cases. + Focus on practical examples and best practices. + In the response, you should only provide the commands or sequence of commands ready to copy and execute + in the command line without any explanation formatting or code blocks, without ```powershell``` or ```cmd```, ` or ``` symbols. + + ru: | + Вы эксперт по Windows командам и командной строке. + Проанализируйте задачу пользователя на естественном языке и предложите Windows команду или набор команд (PowerShell, CMD или batch), которые помогут выполнить эту задачу, и предоставьте подробное объяснение того, что она делает, её параметры и возможные случаи использования. + Сосредоточьтесь на практических примерах и лучших практиках. + В ответе должна присутствовать только команда или последовательность команд, + готовая к копированию и выполнению в командной строке + без объяснений, выделений и форматирования наподобие ```powershell``` или ```cmd```, без символов ` или ```. + + - id: 2 + name: "windows-command-with-explanation" + description: + en: "Prompt with detailed command explanation" + ru: "Промпт с подробным объяснением команд" + content: + en: | + You are a Windows system administrator with extensive experience. + Generate Windows commands based on user task descriptions and provide comprehensive explanations. + + Provide a detailed analysis including: + 1. **Generated Command**: The Windows command that accomplishes the task + 2. **Command Breakdown**: Explain each part of the command + 3. **Parameters**: Explain each flag and option used + 4. **Examples**: Show practical usage scenarios + 5. **Security**: Highlight any security considerations + 6. **Alternatives**: Suggest similar commands if applicable + 7. **Best Practices**: Recommend optimal usage + + Use clear formatting with headers and bullet points for readability. + ru: | + Вы системный администратор Windows с обширным опытом. + Генерируйте Windows команды на основе описаний задач пользователей и предоставляйте исчерпывающие объяснения. + + Предоставьте подробный анализ, включая: + 1. **Сгенерированная команда**: Windows команда, которая выполняет задачу + 2. **Разбор команды**: Объясните каждую часть команды + 3. **Параметры**: Объясните каждый используемый флаг и опцию + 4. **Примеры**: Покажите практические сценарии использования + 5. **Безопасность**: Выделите любые соображения безопасности + 6. **Альтернативы**: Предложите похожие команды, если применимо + 7. **Лучшие практики**: Рекомендуйте оптимальное использование + + Используйте четкое форматирование с заголовками и маркерами для читаемости. + + - id: 3 + name: "windows-command-safe" + description: + en: "Safe command analysis with warnings" + ru: "Безопасный анализ команд с предупреждениями" + content: + en: | + You are a Windows security expert. Generate safe Windows commands based on user task descriptions with a focus on safety and security implications. + + Provide a security-focused analysis: + 1. **Generated Safe Command**: The secure Windows command for the task + 2. **Safety Assessment**: Why this command is safe to run + 3. **Potential Risks**: What could go wrong and how to mitigate + 4. **Data Impact**: What files or data might be affected + 5. **Permissions**: What permissions are required + 6. **Recovery**: How to undo changes if needed + 7. **Best Practices**: Safe alternatives or precautions + 8. **Warnings**: Critical safety considerations + + Always prioritize user safety and data protection. + ru: | + Вы эксперт по безопасности Windows. Генерируйте безопасные Windows команды на основе описаний задач пользователей с акцентом на безопасность и последствия для безопасности. + + Предоставьте анализ, ориентированный на безопасность: + 1. **Сгенерированная безопасная команда**: Безопасная Windows команда для задачи + 2. **Оценка безопасности**: Почему эта команда безопасна для выполнения + 3. **Потенциальные риски**: Что может пойти не так и как это смягчить + 4. **Воздействие на данные**: Какие файлы или данные могут быть затронуты + 5. **Разрешения**: Какие разрешения требуются + 6. **Восстановление**: Как отменить изменения при необходимости + 7. **Лучшие практики**: Безопасные альтернативы или меры предосторожности + 8. **Предупреждения**: Критические соображения безопасности + + Всегда приоритизируйте безопасность пользователя и защиту данных. + + - id: 4 + name: "windows-command-verbose" + description: + en: "Detailed analysis with technical details" + ru: "Подробный анализ с техническими деталями" + content: + en: | + You are a Windows kernel and system expert. Generate Windows commands based on user task descriptions and provide an in-depth technical analysis. + + Deliver a comprehensive technical breakdown: + 1. **Generated Command**: The Windows command that accomplishes the task + 2. **System Level**: How the command interacts with the Windows kernel + 3. **Process Flow**: Step-by-step execution details + 4. **Resource Usage**: CPU, memory, I/O implications + 5. **Registry**: Impact on Windows registry + 6. **Services**: Windows services interactions + 7. **Performance**: Optimization considerations + 8. **Debugging**: Troubleshooting approaches + 9. **Advanced Usage**: Expert-level techniques + + Include technical details, system calls, and low-level operations. + ru: | + Вы эксперт по ядру Windows и системам. Генерируйте Windows команды на основе описаний задач пользователей и предоставляйте глубокий технический анализ. + + Предоставьте исчерпывающий технический разбор: + 1. **Сгенерированная команда**: Windows команда, которая выполняет задачу + 2. **Системный уровень**: Как команда взаимодействует с ядром Windows + 3. **Поток выполнения**: Детали пошагового выполнения + 4. **Использование ресурсов**: Последствия для CPU, памяти, I/O + 5. **Реестр**: Воздействие на реестр Windows + 6. **Службы**: Взаимодействие со службами Windows + 7. **Производительность**: Соображения по оптимизации + 8. **Отладка**: Подходы к устранению неполадок + 9. **Продвинутое использование**: Техники экспертного уровня + + Включите технические детали, системные вызовы и низкоуровневые операции. + + - id: 5 + name: "windows-command-simple" + description: + en: "Simple and clear explanation" + ru: "Простое и понятное объяснение" + content: + en: | + You are a friendly Windows mentor. Explain the given command in simple, easy-to-understand terms. + + Command: {{.command}} + + Provide a beginner-friendly explanation: + 1. **What it does**: Simple, clear description + 2. **Why use it**: Common reasons to use this command + 3. **Basic example**: Simple usage example + 4. **What to expect**: Expected output or behavior + 5. **Tips**: Helpful hints for beginners + + Use plain language, avoid jargon, and focus on practical understanding. + ru: | + Вы дружелюбный наставник по Windows. Объясните данную команду простыми, понятными терминами. + + Команда: {{.command}} + + Предоставьте объяснение, подходящее для начинающих: + 1. **Что она делает**: Простое, четкое описание + 2. **Зачем использовать**: Общие причины использования этой команды + 3. **Базовый пример**: Простой пример использования + 4. **Что ожидать**: Ожидаемый вывод или поведение + 5. **Советы**: Полезные подсказки для начинающих + + Используйте простой язык, избегайте жаргона и сосредоточьтесь на практическом понимании. + + - id: 6 + name: "verbose-v" + description: + en: "Prompt for v mode (basic explanation)" + ru: "Промпт для режима v (базовое объяснение)" + content: + en: | + You are a Windows command expert. You can provide a clear and concise explanation of the given Windows command. + Your explanation should include: + 1. What this command does for the task + 2. Main parameters and their purpose + 3. Common use cases + 4. Any important warnings or considerations + ru: | + Вы эксперт по Windows командам. Вы можете предоставьте четкое и краткое объяснение заданной Windows команды. + Ваши краткие объяснения должны включать: + 1. Что делает эта команда + 2. Основные параметры и их назначение + 3. Общие случаи использования + 4. Любые важные предупреждения или соображения + + - id: 7 + name: "verbose-vv" + description: + en: "Prompt for vv mode (detailed explanation)" + ru: "Промпт для режима vv (подробное объяснение)" + content: + en: | + You are a Windows system expert. Provide a detailed technical explanation of the given command. + + Provide a comprehensive analysis: + 1. **Command Purpose**: What it accomplishes + 2. **Syntax Breakdown**: Detailed parameter analysis + 3. **Technical Details**: How it works internally + 4. **Use Cases**: Practical scenarios and examples + 5. **Performance Impact**: Resource usage and optimization + 6. **Security Considerations**: Potential risks and mitigations + 7. **Advanced Usage**: Expert techniques and tips + 8. **Troubleshooting**: Common issues and solutions + + Include technical depth while maintaining clarity. + ru: | + Вы эксперт по Windows системам. Предоставьте подробное техническое объяснение заданной команды. + + Предоставьте исчерпывающий анализ: + 1. **Цель команды**: Что она достигает + 2. **Разбор синтаксиса**: Подробный анализ параметров + 3. **Технические детали**: Как она работает внутренне + 4. **Случаи использования**: Практические сценарии и примеры + 5. **Влияние на производительность**: Использование ресурсов и оптимизация + 6. **Соображения безопасности**: Потенциальные риски и меры по их снижению + 7. **Продвинутое использование**: Экспертные техники и советы + 8. **Устранение неполадок**: Общие проблемы и решения + + Включите техническую глубину, сохраняя ясность. + + - id: 8 + name: "verbose-vvv" + description: + en: "Prompt for vvv mode (maximum detailed explanation)" + ru: "Промпт для режима vvv (максимально подробное объяснение)" + content: + en: | + You are a Windows kernel and system architecture expert. Provide an exhaustive technical analysis of the given command. + + Deliver a comprehensive technical deep-dive: + 1. **System Architecture**: How it fits into the Windows ecosystem + 2. **Kernel Interaction**: System calls and kernel operations + 3. **Process Management**: Process creation, scheduling, and lifecycle + 4. **Memory Management**: Memory allocation and management + 5. **Registry Operations**: Registry I/O operations and impact + 6. **Network Stack**: Network operations and protocols + 7. **Security Model**: Permissions, capabilities, and security implications + 8. **Performance Analysis**: CPU, memory, I/O, and network impact + 9. **Debugging and Profiling**: Advanced troubleshooting techniques + 10. **Source Code Analysis**: Key implementation details + 11. **Alternative Implementations**: Different approaches and trade-offs + 12. **Historical Context**: Evolution and development history + + Provide maximum technical depth with system-level insights, code examples, and architectural understanding. + ru: | + Вы эксперт по ядру Windows и системной архитектуре. Предоставьте исчерпывающий технический анализ заданной команды. + + Предоставьте исчерпывающий технический глубокий анализ: + 1. **Системная архитектура**: Как она вписывается в экосистему Windows + 2. **Взаимодействие с ядром**: Системные вызовы и операции ядра + 3. **Управление процессами**: Создание, планирование и жизненный цикл процессов + 4. **Управление памятью**: Выделение и управление памятью + 5. **Операции реестра**: I/O операции реестра и воздействие + 6. **Сетевой стек**: Сетевые операции и протоколы + 7. **Модель безопасности**: Разрешения, возможности и последствия безопасности + 8. **Анализ производительности**: Воздействие на CPU, память, I/O и сеть + 9. **Отладка и профилирование**: Продвинутые техники устранения неполадок + 10. **Анализ исходного кода**: Ключевые детали реализации + 11. **Альтернативные реализации**: Разные подходы и компромиссы + 12. **Исторический контекст**: Эволюция и история разработки + + Предоставьте максимальную техническую глубину с системными инсайтами, примерами кода и архитектурным пониманием. diff --git a/gpt/prompts.go b/gpt/prompts.go index 5fa19a3..28eb7ce 100644 --- a/gpt/prompts.go +++ b/gpt/prompts.go @@ -57,6 +57,7 @@ func (pm *PromptManager) createInitialPromptsFile() { pm.Language = "ru" // Загружаем все встроенные промпты из YAML на русском языке + // Функция GetBuiltinPromptsByLanguage уже учитывает операционную систему pm.Prompts = GetBuiltinPromptsByLanguage("ru") // Сохраняем все промпты в файл @@ -65,40 +66,8 @@ func (pm *PromptManager) createInitialPromptsFile() { // loadDefaultPrompts загружает предустановленные промпты func (pm *PromptManager) LoadDefaultPrompts() { - defaultPrompts := []SystemPrompt{ - { - ID: 1, - Name: "linux-command", - Description: "Generate Linux commands (default)", - Content: "Reply with linux command and nothing else. Output with plain response - no need formatting. No need explanation. No need code blocks. No need ` symbols.", - }, - { - ID: 2, - Name: "linux-command-with-explanation", - Description: "Generate Linux commands with explanation", - Content: "Generate a Linux command and provide a brief explanation of what it does. Format: COMMAND: explanation", - }, - { - ID: 3, - Name: "linux-command-safe", - Description: "Generate safe Linux commands", - Content: "Generate a safe Linux command that won't cause data loss or system damage. Reply with linux command and nothing else. Output with plain response - no need formatting.", - }, - { - ID: 4, - Name: "linux-command-verbose", - Description: "Generate Linux commands with detailed explanation", - Content: "Generate a Linux command and provide detailed explanation including what each flag does and potential alternatives.", - }, - { - ID: 5, - Name: "linux-command-simple", - Description: "Generate simple Linux commands", - Content: "Generate a simple, easy-to-understand Linux command. Avoid complex flags and options when possible.", - }, - } - - pm.Prompts = defaultPrompts + // Используем встроенные промпты, которые автоматически выбираются по ОС + pm.Prompts = GetBuiltinPromptsByLanguage("en") } // loadAllPrompts загружает все промпты из файла sys_prompts diff --git a/gpt/providers.go b/gpt/providers.go index f84e6ec..e809d30 100644 --- a/gpt/providers.go +++ b/gpt/providers.go @@ -8,6 +8,8 @@ import ( "net/http" "strings" "time" + + "github.com/direct-dev-ru/linux-command-gpt/config" ) // Provider интерфейс для работы с разными LLM провайдерами @@ -112,7 +114,7 @@ func (p *ProxyAPIProvider) Chat(messages []Chat) (string, error) { return "", fmt.Errorf("ошибка маршалинга запроса: %w", err) } - req, err := http.NewRequest("POST", p.BaseURL+"/api/v1/protected/sberchat/chat", bytes.NewBuffer(jsonData)) + req, err := http.NewRequest("POST", p.BaseURL+config.AppConfig.Server.ProxyUrl, bytes.NewBuffer(jsonData)) if err != nil { return "", fmt.Errorf("ошибка создания запроса: %w", err) } @@ -155,7 +157,7 @@ func (p *ProxyAPIProvider) Chat(messages []Chat) (string, error) { // Health для ProxyAPIProvider func (p *ProxyAPIProvider) Health() error { - req, err := http.NewRequest("GET", p.BaseURL+"/api/v1/protected/sberchat/health", nil) + req, err := http.NewRequest("GET", p.BaseURL+config.AppConfig.Server.HealthUrl, nil) if err != nil { return fmt.Errorf("ошибка создания health check запроса: %w", err) } diff --git a/kustomize/configmap.yaml b/kustomize/configmap.yaml new file mode 100644 index 0000000..d4ad62f --- /dev/null +++ b/kustomize/configmap.yaml @@ -0,0 +1,46 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: lcg-config + namespace: lcg +data: + # Основные настройки + LCG_VERSION: "v2.0.2" + LCG_BASE_PATH: "/lcg" + LCG_SERVER_HOST: "0.0.0.0" + LCG_SERVER_PORT: "8080" + LCG_SERVER_ALLOW_HTTP: "true" + LCG_APP_NAME: "Linux Command GPT" + LCG_RESULT_FOLDER: "/app/data/results" + LCG_PROMPT_FOLDER: "/app/data/prompts" + LCG_CONFIG_FOLDER: "/app/data/config" + LCG_NO_HISTORY: "false" + LCG_ALLOW_EXECUTION: "false" + LCG_DEBUG: "false" + + # Настройки аутентификации + LCG_SERVER_REQUIRE_AUTH: "true" + + LCG_COOKIE_SECURE: "true" + LCG_COOKIE_TTL_HOURS: "168" + LCG_DOMAIN: "direct-dev.ru" + LCG_COOKIE_PATH: "/lcg" + + # Настройки провайдера (по умолчанию) + LCG_PROVIDER_TYPE: "proxy" + LCG_HOST: "https://direct-dev.ru" + LCG_HEALTH_URL: "/api/v1/protected/sberchat/health" + LCG_PROXY_URL: "/api/v1/protected/sberchat/chat" + LCG_MODEL: "GigaChat-2-Max" + + # Настройки валидации + LCG_MAX_SYSTEM_PROMPT_LENGTH: "2000" + LCG_MAX_USER_MESSAGE_LENGTH: "4000" + LCG_MAX_PROMPT_NAME_LENGTH: "2000" + LCG_MAX_PROMPT_DESC_LENGTH: "50000" + + # Настройки таймаутов + LCG_TIMEOUT: "300" + + # Настройки отладки + LCG_DEBUG: "false" diff --git a/kustomize/deployment.yaml b/kustomize/deployment.yaml new file mode 100644 index 0000000..6bf7254 --- /dev/null +++ b/kustomize/deployment.yaml @@ -0,0 +1,95 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lcg + namespace: lcg + labels: + app: lcg + version: v2.0.2 +spec: + replicas: 1 + selector: + matchLabels: + app: lcg + template: + metadata: + labels: + app: lcg + version: v2.0.2 + spec: + containers: + - name: lcg + image: kuznetcovay/lcg:v2.0.2 + imagePullPolicy: Always + ports: + - containerPort: 8080 + protocol: TCP + envFrom: + - configMapRef: + name: lcg-config + - secretRef: + name: lcg-secret + env: + # Pod information + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: NODE_NAME + valueFrom: + fieldRef: + fieldPath: spec.nodeName + resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 250m + memory: 512Mi + volumeMounts: + - name: lcg-data + mountPath: /app/data + - name: lcg-config + mountPath: /app/config + readOnly: true + # Health checks + startupProbe: + httpGet: + path: /login + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 + failureThreshold: 30 + readinessProbe: + httpGet: + path: /login + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /login + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 60 + volumes: + - name: lcg-data + persistentVolumeClaim: + claimName: lcg-data + - name: lcg-config + configMap: + name: lcg-config + # Security context + securityContext: + runAsNonRoot: true + runAsUser: 1001 + readOnlyRootFilesystem: false + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + restartPolicy: Always diff --git a/kustomize/ingress-route.yaml b/kustomize/ingress-route.yaml new file mode 100644 index 0000000..1f47066 --- /dev/null +++ b/kustomize/ingress-route.yaml @@ -0,0 +1,64 @@ +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: lcg-route + namespace: lcg + labels: + app: lcg + version: v2.0.2 +spec: + entryPoints: + - websecure + routes: + - kind: Rule + match: Host(`direct-dev.ru`) && PathPrefix(`/lcg`) + services: + - name: lcg + port: 8080 + tls: + secretName: le-root-direct-dev-ru +--- + +# apiVersion: traefik.io/v1alpha1 +# kind: IngressRoute +# metadata: +# name: lcg-route +# namespace: lcg +# spec: +# entryPoints: +# - websecure +# routes: +# - kind: Rule +# match: Host(`direct-dev.ru`) && PathPrefix(`/lcg`) +# services: +# - name: lcg +# port: 8080 +# middlewares: +# - name: lcg-strip-prefix +# tls: +# secretName: le-root-direct-dev-ru +# --- +# apiVersion: traefik.io/v1alpha1 +# kind: Middleware +# metadata: +# name: lcg-strip-prefix +# namespace: lcg +# spec: +# stripPrefix: +# prefixes: +# - /lcg +# --- +# apiVersion: traefik.io/v1alpha1 +# kind: Middleware +# metadata: +# name: lcg-headers +# namespace: lcg +# spec: +# headers: +# customRequestHeaders: +# X-Forwarded-Proto: "https" +# X-Forwarded-Port: "443" +# customResponseHeaders: +# X-Frame-Options: "DENY" +# X-Content-Type-Options: "nosniff" +# X-XSS-Protection: "1; mode=block" diff --git a/kustomize/kustomization.yaml b/kustomize/kustomization.yaml new file mode 100644 index 0000000..8286315 --- /dev/null +++ b/kustomize/kustomization.yaml @@ -0,0 +1,25 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# Namespace +namespace: lcg + +# Resources +resources: + - configmap.yaml + - secret.yaml + - deployment.yaml + - service.yaml + - ingress-route.yaml + +# Common labels +commonLabels: + app: lcg + version: v2.0.2 + managed-by: kustomize + +# Images +images: + - name: lcg + newName: kuznetcovay/lcg + newTag: v2.0.2 diff --git a/kustomize/secret.yaml b/kustomize/secret.yaml new file mode 100644 index 0000000..4962408 --- /dev/null +++ b/kustomize/secret.yaml @@ -0,0 +1,18 @@ +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + creationTimestamp: null + name: lcg-secrets + namespace: lcg +spec: + encryptedData: + LCG_CSRF_SECRET: AgDYqxr2Xlf0Hmqgb9u1OildXogus4pFcqB80Dfc1uYw7wnHNOZZP+Hto1OAifXGg2eOYbB1QEsbW/lLSim/C2zpAjNUfRTkwDhQhK3FWmLOjruKBYHZxHyMuhbBmGhVTFbKApOc9zQXTASMLgIeP2vK5yDhVRr97/OJmMKeTOq8FX8CxXZPKTjehronFS3syUlIocboi0PveFf0dgj7nkYePBMST/FKGe2I1NbpOXYH2VrZWOHeoLYDUrEQMQvSAS1mnxAPkdEOuqbWMJA7cs+KKiIFsoy5eBkOXe8hoQqLfddi7ifnJ6h32StQg67qrAm4IMClS/U9iZK5C3Rm/COsywp8Mp3e1iF0hQ2yPuiHMBibxb3aRKwIryJjeo7x51PMMjJaErbTU+bwaAvMav7znOQ14N410bp2Io6KBNsOLW0DG8/mvmQcEn4Q5f4ZtSLcvaq5BQ0NjzrrIv+eCyvNzA28oAyuR0VieJdrUPqpgELrT64MwC+4m5dqdrNPdWkxbOXPG8ghs1nMSSfI+aU+JjT1vupvJA8m83NPqh+YBewmZiTwq5PdvnlAYZ+SYFLZwDWpdUTnK/hqiUkSDOJbE0pbcm03BaG9FjTJpNsmOcKu05Rs8QfWFJMOqYKC4dTA1itxTkyCxz9Y8KFJzX0RXuksN19xzLiZm+e586VtxExEfkYuBo01R39LOtTey9e8pIB9dKwFsHRAORDaZwkaKRe3RlyF8FoSq4khr79CYdGw6IAYzrg8sXAbWawXPg1osQpNR825PejO3/TwbJDPKfjo8RaHHiFa7UhaHbrm/4FwAj4gSGVL + LCG_JWT_SECRET: AgBq2Eb0fh1lO0FP2/ebnwuyiTyrAF+dD3iu7uLVgo7JuLnKRA4998rB1OZGRcZ+MNLpajIa20ezJ3aV2wCl24PSJErhzKC3IPSXoLnpJ0EfXrFGS0Pd86TQqAf0TMN2ALFrHY/LywYW09I90/Ct93WqjDKY4yVPbyVni+njUSwavp0iMEWDjENRTdWDK3f/ym1i8SMJgSjX5N6dazwyWVSeWC8oHAGPBF8rr+rhPnY1Ds7QeR/8M0GK7YLiI2nnKljxo7LjXA61jbgCLyiATcWYRylkylcO+ZU8bAsaOSJGXEjtO6s0GHY7Y9CcHQlb1VTZCVfzu/Pb7OSbIiL4wVWHi0DYScTJluVr5SorUjti8HprW9LJE0sy7GIxMdsRZ/7jfJY/s1nxFJph3Q+wkImoPD7R0Njpkwph14CH3xfhPxBpXTQO14hlhx1VYtVkYeWAtcFht+z1qFz0vn+eYkm9B28U+2uxP4WA5AyyMZBPJVekUxQiEmr2nnowbV+zu0+wRtor/lPREQBInbf9tpvth5fpnXtUCS4P2Ne4WWlY0Y5Sij5Z20l0FA0CwmMOliWUcl9vIAy+of3RvM9XGgGqMWTuJP2QFEt7E8VKzM/aI8XgdJuExSCDjd/cyOIgnz5Byy1YvdICqL1CrVpvL0mdy+KMobr59Fgdv+5q0hiHvXF8p2bkG1DHgWiSK3WARDpKzAs+cfsPomD3jMhJZFOopp1Vu9Jpy+l4IsWoY8W1KajPW2DQWjwULRt+ewNr1mAfj+8ESVSXH8zqLv+hkXeef28h7McPcfH9fx9ejs9ovS/E6Sks6EWp + LCG_JWT_TOKEN: AgB6PPDflcIav6fqhCi80Ysv9HPkI5zXIjqfot3jaYON3fNmpKNDIhyvKk4YvLbT4PEZfY+JZP/f17MoJ1eikeiZAO7klkg3wNq3h6TcRTuwM/ST9R/KsWqnfLxm5HzGBsqh39cwv9eU2ovAMXqXPJeO/23HcjOqZg7cWZ2WfknaAUydJc39Cue4zmgxlpIxF37p6/rvJqUGByOOUzlDHoVV3TORi+j6dui352PGG6gVCzcCVGNSsbf4j1VibJ1Lz06WEayMi7ZYkD18rsiKHcFGo2SBhEjGGo00Cbdq0EOUTu6k1Q47evHMLFAhdFK3T2gESB4NfMaAL+6gHS7ouI6SbyOCeAZIGT8e3ggM7MlIsNBrLUeDLEwZG8DjHGItY9KcJG/YxbjZ24b9/IzWDduR8VIUG6XCIrGwQd2jlH8GXmrsq+3KkQr81Tj6Z4/QIa4mcgqSKBr8nCzf31GQhhWgj143VwZtPuHUaAbSsZ4ISbo1PoISUaHymWh1J6qjjrzsvfOdeiKHihA8nLe4ggnV8nrQ5EusA+DzmL/Ti+K+2cc277nC0J1pFhuZs37xi+eQT6TMyUeE12uyCHlG1SiwG8t0wfv2N/yzdugW2eILZbRDZnEoN46lLoeXrTGRiFi25/6Jue0/iTo1AV7ameK4J2teGIhYROqB06kResWVECWm2mWhMpJ7Am5ij7tho3Ot75wrmgXXWCb962MzmIpJG8VIimtoIRNVtlu7+cxMb4D9KFb/i69cMkb+7R+Vm6c37T0T0R/o6QCY+MP0w29xgbGz5PNcLEh//avz4E1JI+AsbvtHOi8/aZ9F7c2DcTfXDcxoA2suxJjRy8Y4uu6rrKUWhklla5G/hs0rZsuTM9iruFasV+AybXDLN2/YNqSAj4oDzi/lYNwvQm5CTZwklHK/fwNPbfCNkY5C94rvzW+OJJ2mR1rcHCfHVWYW/IQRfE11mZVX2m0HT70rDPVopYSHrmlvuuTk3ky5gXym+/FOKBq+BcE0GiDDGl3C5VFtiREhpW4J7zRux5QnHk5fIVyEAZlidKsNSNLwq73+E6W77kMNDU7KCRH23A9BIJPOpN87oZDVX1eBghiM/qBOzP04fw3C6dxu+W/OQvTwZmxLtod07Y64EbdaeqJtjnd7GihAEW2jj+Wkcfz9WHTw38cNpyLqcU3ap13790qVJO6V27b0OmiEiloMYyYHUwcHs8wQA946ns0XOz7zw3r1goJgJS6il93dAThK2UBzw3DIY4yJGrmscPZesWSqL3a+ElGjZWz6n9idmIN7L7oViR7A+p17zwFnpczz/VsV+vj8DwSBwLsw6Q== + LCG_SERVER_PASSWORD: AgBYXu3YpewbdveXVFDGKBzJe8Gur70LxYSL3kmM7Q65lTU/Q4smpPKhb/bDPntNc4XmNFUfVZ/P5iv2bCdgZB0ccMp6eGZUKKfa7PVJ1/I1hAVwSWYmkD673tbfF7CoGrgtTYw93IaU4t1aWBRiqgapktRawFY1SEIoU6dAx3nrKivyFFJx5akHnm+7vr0GMAtE+H8P/htW+bg1peg5rVYkQwMyeYdefQ+AA/RMDG74XlRGv0EfU6S/LmJ0HF84jb5VNjyJMAD7NssuSUXglpVhfTRwZZAD789/hTgElQnR1JUAIyRHeY6zerU5sXaaS7l+MJGxMMNGsfgOFQ57kzA7Eafumf0ChKfdGl7c4UaEcq6zENSp1Rrzc84WLoghwhvVIhszuGkSE9aLelJJamBMOnm/rlwOuQJJsMAlTiuGO7pewsHKDQXnuZGKOQyAflEX/SNXzUSdYEGonuWGkjWopZrvjO9TwBReFUfsV/ALoDgjA2b7nFpXff0Ffx+EvYtuudDZFw1PobBAHy2aFkMuUP0GxVh14nbmC38VNMiug4+xfl92TxbxRkOkx/tufb2p2QFaglW5TK9I3ysHxiCODhq+YdhlW+gDS+mzBEQZbXd+3TMjh7sE1DGqbloMDhfvJJA6u6C2t6olW3BghU/AS20tOgNC+aIgWzetC6ODOKJQLAq2jjXG+PmUoJDUhs1SbS7uIsIlLIt/Y62b6roVAK81zQPt3w== + template: + metadata: + creationTimestamp: null + name: lcg-secrets + namespace: lcg +--- diff --git a/kustomize/service.yaml b/kustomize/service.yaml new file mode 100644 index 0000000..c278abc --- /dev/null +++ b/kustomize/service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: lcg + namespace: lcg + labels: + app: lcg + version: v2.0.2 +spec: + type: ClusterIP + ports: + - port: 8080 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: lcg + version: v2.0.2 diff --git a/main.go b/main.go index f40d270..be73063 100644 --- a/main.go +++ b/main.go @@ -21,12 +21,20 @@ import ( "github.com/direct-dev-ru/linux-command-gpt/serve" "github.com/direct-dev-ru/linux-command-gpt/validation" "github.com/urfave/cli/v2" + "gopkg.in/yaml.v3" ) //go:embed VERSION.txt var Version string -// используем глобальный экземпляр конфига из пакета config +//go:embed build-conditions.yaml +var BuildConditionsFromYaml string + +type buildConditions struct { + NoServe bool `yaml:"no-serve"` +} + +var CompileConditions buildConditions // disableHistory управляет записью/обновлением истории на уровне процесса (флаг имеет приоритет над env) var disableHistory bool @@ -46,6 +54,14 @@ const ( ) func main() { + + if err := yaml.Unmarshal([]byte(BuildConditionsFromYaml), &CompileConditions); err != nil { + fmt.Println("Error parsing build conditions:", err) + CompileConditions.NoServe = false + } + + fmt.Println("Build conditions:", CompileConditions) + _ = colorBlue gpt.InitBuiltinPrompts("") @@ -57,7 +73,7 @@ func main() { app := &cli.App{ Name: "lcg", - Usage: "Linux Command GPT - Генерация Linux команд из описаний", + Usage: config.AppConfig.AppName + " - Генерация Linux команд из описаний", Version: Version, Commands: getCommands(), UsageText: ` @@ -68,7 +84,7 @@ lcg [опции] <описание команды> lcg --file /path/to/file.txt "хочу вывести все директории с помощью ls" `, Description: ` -Linux Command GPT - инструмент для генерации Linux команд из описаний на естественном языке. +{{.AppName}} - инструмент для генерации Linux команд из описаний на естественном языке. Поддерживает чтение частей промпта из файлов и позволяет сохранять, копировать или перегенерировать результаты. может задавать системный промпт или выбирать из предустановленных промптов. Переменные окружения: @@ -159,6 +175,12 @@ Linux Command GPT - инструмент для генерации Linux ком } } + if CompileConditions.NoServe { + if len(args) > 1 && args[0] == "serve" { + printColored("❌ Error: serve command is disabled in this build\n", colorRed) + os.Exit(1) + } + } executeMain(file, system, strings.Join(args, " "), timeout) return nil }, @@ -180,7 +202,7 @@ Linux Command GPT - инструмент для генерации Linux ком } func getCommands() []*cli.Command { - return []*cli.Command{ + commands := []*cli.Command{ { Name: "update-key", Aliases: []string{"u"}, @@ -578,7 +600,15 @@ func getCommands() []*cli.Command { if host == "0.0.0.0" { browserHost = "localhost" } - url := fmt.Sprintf("%s://%s:%s", protocol, browserHost, port) + + // Учитываем BasePath в URL + basePath := config.AppConfig.Server.BasePath + if basePath == "" || basePath == "/" { + basePath = "" + } else { + basePath = strings.TrimSuffix(basePath, "/") + } + url := fmt.Sprintf("%s://%s:%s%s", protocol, browserHost, port, basePath) if openBrowser { printColored("🌍 Открываю браузер...\n", colorGreen) @@ -596,6 +626,19 @@ func getCommands() []*cli.Command { }, }, } + + if CompileConditions.NoServe { + filteredCommands := []*cli.Command{} + for _, cmd := range commands { + if cmd.Name != "serve" { + filteredCommands = append(filteredCommands, cmd) + } + } + commands = filteredCommands + } + + return commands + } func executeMain(file, system, commandInput string, timeout int) { @@ -972,7 +1015,6 @@ func showFullConfig() { type SafeConfig struct { Cwd string `json:"cwd"` Host string `json:"host"` - ProxyUrl string `json:"proxy_url"` Completions string `json:"completions"` Model string `json:"model"` Prompt string `json:"prompt"` @@ -995,7 +1037,6 @@ func showFullConfig() { safeConfig := SafeConfig{ Cwd: config.AppConfig.Cwd, Host: config.AppConfig.Host, - ProxyUrl: config.AppConfig.ProxyUrl, Completions: config.AppConfig.Completions, Model: config.AppConfig.Model, Prompt: config.AppConfig.Prompt, @@ -1024,6 +1065,8 @@ func showFullConfig() { Validation: config.AppConfig.Validation, } + safeConfig.Server.Password = "***" + // Выводим JSON с отступами jsonData, err := json.MarshalIndent(safeConfig, "", " ") if err != nil { diff --git a/main_test.go b/main_test.go deleted file mode 100644 index 06ab7d0..0000000 --- a/main_test.go +++ /dev/null @@ -1 +0,0 @@ -package main diff --git a/serve/auth.go b/serve/auth.go new file mode 100644 index 0000000..f26c562 --- /dev/null +++ b/serve/auth.go @@ -0,0 +1,269 @@ +package serve + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "net/http" + "os" + "time" + + "github.com/direct-dev-ru/linux-command-gpt/config" + "github.com/golang-jwt/jwt/v5" +) + +// JWTClaims представляет claims для JWT токена +type JWTClaims struct { + Username string `json:"username"` + jwt.RegisteredClaims +} + +// AuthRequest представляет запрос на аутентификацию +type AuthRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// AuthResponse представляет ответ на аутентификацию +type AuthResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Error string `json:"error,omitempty"` +} + +// JWTSecretKey генерирует или загружает секретный ключ для JWT +func getJWTSecretKey() ([]byte, error) { + // Пытаемся загрузить из переменной окружения + if secret := os.Getenv("LCG_JWT_SECRET"); secret != "" { + return []byte(secret), nil + } + + // Пытаемся загрузить из файла + secretFile := fmt.Sprintf("%s/server/jwt_secret", config.AppConfig.Server.ConfigFolder) + if data, err := os.ReadFile(secretFile); err == nil { + return data, nil + } + + // Генерируем новый секретный ключ + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return nil, fmt.Errorf("failed to generate JWT secret: %v", err) + } + + // Создаем директорию если не существует + if err := os.MkdirAll(fmt.Sprintf("%s/server", config.AppConfig.Server.ConfigFolder), 0755); err != nil { + return nil, fmt.Errorf("failed to create config directory: %v", err) + } + + // Сохраняем секретный ключ в файл + if err := os.WriteFile(secretFile, secret, 0600); err != nil { + return nil, fmt.Errorf("failed to save JWT secret: %v", err) + } + + return secret, nil +} + +// generateJWTToken создает JWT токен для пользователя +func generateJWTToken(username string) (string, error) { + secret, err := getJWTSecretKey() + if err != nil { + return "", err + } + + // Создаем claims + claims := JWTClaims{ + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), // Токен действителен 24 часа + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "lcg-server", + }, + } + + // Создаем токен + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(secret) +} + +// validateJWTToken проверяет JWT токен +func validateJWTToken(tokenString string) (*JWTClaims, error) { + secret, err := getJWTSecretKey() + if err != nil { + return nil, err + } + + // Парсим токен + token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) { + // Проверяем метод подписи + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return secret, nil + }) + + if err != nil { + return nil, err + } + + // Проверяем валидность токена + if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid { + return claims, nil + } + + return nil, fmt.Errorf("invalid token") +} + +// getTokenFromCookie извлекает JWT токен из cookies +func getTokenFromCookie(r *http.Request) (string, error) { + cookie, err := r.Cookie("auth_token") + if err != nil { + return "", err + } + return cookie.Value, nil +} + +// setAuthCookie устанавливает HTTP-only cookie с JWT токеном +func setAuthCookie(w http.ResponseWriter, token string) { + cookie := &http.Cookie{ + Name: "auth_token", + Domain: config.AppConfig.Server.Domain, + Value: token, + Path: config.AppConfig.Server.CookiePath, + HttpOnly: true, + Secure: config.AppConfig.Server.CookieSecure, + SameSite: http.SameSiteLaxMode, // Более мягкий режим для reverse proxy + MaxAge: config.AppConfig.Server.CookieTTLHours * 60 * 60, + } + + // Добавляем домен если указан + if config.AppConfig.Server.Domain != "" { + cookie.Domain = config.AppConfig.Server.Domain + } + + http.SetCookie(w, cookie) +} + +// clearAuthCookie удаляет cookie с токеном +func clearAuthCookie(w http.ResponseWriter) { + cookie := &http.Cookie{ + Name: "auth_token", + Value: "", + Path: config.AppConfig.Server.CookiePath, + HttpOnly: true, + Secure: config.AppConfig.Server.CookieSecure, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, // Удаляем cookie + } + + // Добавляем домен если указан + if config.AppConfig.Server.Domain != "" { + cookie.Domain = config.AppConfig.Server.Domain + } + + http.SetCookie(w, cookie) +} + +// handleLogin обрабатывает запрос на вход +func handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req AuthRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + apiJsonResponse(w, AuthResponse{ + Success: false, + Error: "Invalid request body", + }) + return + } + + // Проверяем пароль + if req.Password != config.AppConfig.Server.Password { + apiJsonResponse(w, AuthResponse{ + Success: false, + Error: "Неверный пароль", + }) + return + } + + // Генерируем JWT токен + token, err := generateJWTToken(req.Username) + if err != nil { + apiJsonResponse(w, AuthResponse{ + Success: false, + Error: "Failed to generate token", + }) + return + } + + // Устанавливаем cookie + setAuthCookie(w, token) + + apiJsonResponse(w, AuthResponse{ + Success: true, + Message: "Успешная авторизация", + }) +} + +// handleLogout обрабатывает запрос на выход +func handleLogout(w http.ResponseWriter, r *http.Request) { + clearAuthCookie(w) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +// handleValidateToken обрабатывает проверку валидности токена +func handleValidateToken(w http.ResponseWriter, r *http.Request) { + token, err := getTokenFromCookie(r) + if err != nil { + apiJsonResponse(w, AuthResponse{ + Success: false, + Error: "Token not found", + }) + return + } + + _, err = validateJWTToken(token) + if err != nil { + apiJsonResponse(w, AuthResponse{ + Success: false, + Error: "Invalid token", + }) + return + } + + apiJsonResponse(w, AuthResponse{ + Success: true, + Message: "Token is valid", + }) +} + +// requireAuth middleware проверяет аутентификацию +func requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Проверяем, требуется ли аутентификация + if !config.AppConfig.Server.RequireAuth { + next(w, r) + return + } + + // Получаем токен из cookie + token, err := getTokenFromCookie(r) + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + // Проверяем валидность токена + _, err = validateJWTToken(token) + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + // Токен валиден, продолжаем + next(w, r) + } +} diff --git a/serve/csrf.go b/serve/csrf.go new file mode 100644 index 0000000..be047e5 --- /dev/null +++ b/serve/csrf.go @@ -0,0 +1,263 @@ +package serve + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "net/http" + "os" + "time" + + "github.com/direct-dev-ru/linux-command-gpt/config" +) + +// CSRFManager управляет CSRF токенами +type CSRFManager struct { + secretKey []byte +} + +// CSRFData содержит данные для CSRF токена +type CSRFData struct { + Token string + Timestamp int64 + UserID string +} + +// NewCSRFManager создает новый менеджер CSRF +func NewCSRFManager() (*CSRFManager, error) { + secret, err := getCSRFSecretKey() + if err != nil { + return nil, err + } + return &CSRFManager{secretKey: secret}, nil +} + +// getCSRFSecretKey получает или генерирует секретный ключ для CSRF +func getCSRFSecretKey() ([]byte, error) { + // Пытаемся загрузить из переменной окружения + if secret := os.Getenv("LCG_CSRF_SECRET"); secret != "" { + return []byte(secret), nil + } + + // Пытаемся загрузить из файла + secretFile := fmt.Sprintf("%s/server/csrf_secret", config.AppConfig.Server.ConfigFolder) + if data, err := os.ReadFile(secretFile); err == nil { + return data, nil + } + + // Генерируем новый секретный ключ + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return nil, fmt.Errorf("failed to generate CSRF secret: %v", err) + } + + // Создаем директорию если не существует + if err := os.MkdirAll(fmt.Sprintf("%s/server", config.AppConfig.Server.ConfigFolder), 0755); err != nil { + return nil, fmt.Errorf("failed to create config directory: %v", err) + } + + // Сохраняем секретный ключ в файл + if err := os.WriteFile(secretFile, secret, 0600); err != nil { + return nil, fmt.Errorf("failed to save CSRF secret: %v", err) + } + + return secret, nil +} + +// GenerateToken генерирует CSRF токен для пользователя +func (c *CSRFManager) GenerateToken(userID string) (string, error) { + // Создаем данные токена + data := CSRFData{ + Token: generateRandomString(32), + Timestamp: time.Now().Unix(), + UserID: userID, + } + + // Создаем подпись + signature := c.createSignature(data) + + // Кодируем данные в base64 + encodedData := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%d:%s", data.Token, data.Timestamp, data.UserID))) + + return fmt.Sprintf("%s.%s", encodedData, signature), nil +} + +// ValidateToken проверяет CSRF токен +func (c *CSRFManager) ValidateToken(token, userID string) bool { + // Разделяем токен на данные и подпись + parts := splitToken(token) + if len(parts) != 2 { + return false + } + + encodedData, signature := parts[0], parts[1] + + // Декодируем данные + dataBytes, err := base64.StdEncoding.DecodeString(encodedData) + if err != nil { + return false + } + + // Парсим данные + dataParts := splitString(string(dataBytes), ":") + if len(dataParts) != 3 { + return false + } + + tokenValue, timestampStr, tokenUserID := dataParts[0], dataParts[1], dataParts[2] + + // Проверяем пользователя + if tokenUserID != userID { + return false + } + + // Проверяем время жизни токена (24 часа) + timestamp, err := parseInt64(timestampStr) + if err != nil { + return false + } + + if time.Now().Unix()-timestamp > 24*60*60 { + return false + } + + // Создаем данные для проверки подписи + data := CSRFData{ + Token: tokenValue, + Timestamp: timestamp, + UserID: tokenUserID, + } + + // Проверяем подпись + expectedSignature := c.createSignature(data) + return signature == expectedSignature +} + +// createSignature создает подпись для данных +func (c *CSRFManager) createSignature(data CSRFData) string { + message := fmt.Sprintf("%s:%d:%s", data.Token, data.Timestamp, data.UserID) + hash := sha256.Sum256(append(c.secretKey, []byte(message)...)) + return hex.EncodeToString(hash[:]) +} + +// getTokenFromCookie извлекает CSRF токен из cookie +func GetCSRFTokenFromCookie(r *http.Request) string { + cookie, err := r.Cookie("csrf_token") + if err != nil { + return "" + } + return cookie.Value +} + +// setCSRFCookie устанавливает CSRF токен в cookie +func setCSRFCookie(w http.ResponseWriter, token string) { + cookie := &http.Cookie{ + Name: "csrf_token", + Value: token, + Path: config.AppConfig.Server.CookiePath, + HttpOnly: true, + Secure: config.AppConfig.Server.CookieSecure, + SameSite: http.SameSiteLaxMode, // Более мягкий режим для reverse proxy + MaxAge: 1 * 60 * 60, + } + + // Добавляем домен если указан + if config.AppConfig.Server.Domain != "" { + cookie.Domain = config.AppConfig.Server.Domain + } + + http.SetCookie(w, cookie) +} + +// clearCSRFCookie удаляет CSRF cookie +func СlearCSRFCookie(w http.ResponseWriter) { + cookie := &http.Cookie{ + Name: "csrf_token", + Value: "", + Path: config.AppConfig.Server.CookiePath, + HttpOnly: true, + Secure: config.AppConfig.Server.CookieSecure, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + } + + // Добавляем домен если указан + if config.AppConfig.Server.Domain != "" { + cookie.Domain = config.AppConfig.Server.Domain + } + + http.SetCookie(w, cookie) +} + +// generateRandomString генерирует случайную строку +func generateRandomString(length int) string { + bytes := make([]byte, length) + rand.Read(bytes) + return base64.URLEncoding.EncodeToString(bytes)[:length] +} + +// splitToken разделяет токен на части +func splitToken(token string) []string { + // Ищем последнюю точку + lastDot := -1 + for i := len(token) - 1; i >= 0; i-- { + if token[i] == '.' { + lastDot = i + break + } + } + + if lastDot == -1 { + return []string{token} + } + + return []string{token[:lastDot], token[lastDot+1:]} +} + +// splitString разделяет строку по разделителю +func splitString(s, sep string) []string { + if s == "" { + return []string{} + } + + var result []string + start := 0 + for i := 0; i < len(s); i++ { + if i+len(sep) <= len(s) && s[i:i+len(sep)] == sep { + result = append(result, s[start:i]) + start = i + len(sep) + i += len(sep) - 1 + } + } + result = append(result, s[start:]) + return result +} + +// parseInt64 парсит строку в int64 +func parseInt64(s string) (int64, error) { + var result int64 + for _, char := range s { + if char < '0' || char > '9' { + return 0, fmt.Errorf("invalid number: %s", s) + } + result = result*10 + int64(char-'0') + } + return result, nil +} + +// Глобальный экземпляр CSRF менеджера +var csrfManager *CSRFManager + +// InitCSRFManager инициализирует глобальный CSRF менеджер +func InitCSRFManager() error { + var err error + csrfManager, err = NewCSRFManager() + return err +} + +// GetCSRFManager возвращает глобальный CSRF менеджер +func GetCSRFManager() *CSRFManager { + return csrfManager +} diff --git a/serve/execute_page.go b/serve/execute_page.go index 01177b3..934ae65 100644 --- a/serve/execute_page.go +++ b/serve/execute_page.go @@ -23,6 +23,12 @@ type ExecutePageData struct { ResultSection template.HTML VerboseButtons template.HTML ActionButtons template.HTML + CSRFToken string + ProviderType string + Model string + Host string + BasePath string + AppName string // Поля конфигурации для валидации MaxUserMessageLength int } @@ -50,7 +56,7 @@ func handleExecutePage(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: // Показываем форму - showExecuteForm(w) + showExecuteForm(w, r) case http.MethodPost: // Обрабатываем выполнение handleExecuteRequest(w, r) @@ -60,7 +66,25 @@ func handleExecutePage(w http.ResponseWriter, r *http.Request) { } // showExecuteForm показывает форму выполнения -func showExecuteForm(w http.ResponseWriter) { +func showExecuteForm(w http.ResponseWriter, r *http.Request) { + // Генерируем CSRF токен + csrfManager := GetCSRFManager() + if csrfManager == nil { + http.Error(w, "CSRF manager not initialized", http.StatusInternalServerError) + return + } + + // Получаем сессионный ID + sessionID := getSessionID(r) + csrfToken, err := csrfManager.GenerateToken(sessionID) + if err != nil { + http.Error(w, "Failed to generate CSRF token", http.StatusInternalServerError) + return + } + + // Устанавливаем CSRF токен в cookie + setCSRFCookie(w, csrfToken) + // Получаем системные промпты pm := gpt.NewPromptManager(config.AppConfig.PromptFolder) @@ -84,6 +108,12 @@ func showExecuteForm(w http.ResponseWriter) { ResultSection: template.HTML(""), VerboseButtons: template.HTML(""), ActionButtons: template.HTML(""), + CSRFToken: csrfToken, + ProviderType: config.AppConfig.ProviderType, + Model: config.AppConfig.Model, + Host: config.AppConfig.Host, + BasePath: getBasePath(), + AppName: config.AppConfig.AppName, MaxUserMessageLength: config.AppConfig.Validation.MaxUserMessageLength, } @@ -194,6 +224,15 @@ func handleExecuteRequest(w http.ResponseWriter, r *http.Request) { } } + // Генерируем CSRF токен для результата + csrfManager := GetCSRFManager() + sessionID := getSessionID(r) + csrfToken, err := csrfManager.GenerateToken(sessionID) + if err != nil { + http.Error(w, "Failed to generate CSRF token", http.StatusInternalServerError) + return + } + data := ExecutePageData{ Title: "Результат выполнения", Header: "Результат выполнения", @@ -202,6 +241,12 @@ func handleExecuteRequest(w http.ResponseWriter, r *http.Request) { ResultSection: template.HTML(formatResultSection(result)), VerboseButtons: template.HTML(formatVerboseButtons(result)), ActionButtons: template.HTML(formatActionButtons(result)), + CSRFToken: csrfToken, + ProviderType: config.AppConfig.ProviderType, + Model: config.AppConfig.Model, + Host: config.AppConfig.Host, + BasePath: getBasePath(), + AppName: config.AppConfig.AppName, MaxUserMessageLength: config.AppConfig.Validation.MaxUserMessageLength, } diff --git a/serve/history.go b/serve/history.go index e35dc25..efc9953 100644 --- a/serve/history.go +++ b/serve/history.go @@ -5,6 +5,7 @@ import ( "html/template" "net/http" "os" + "sort" "strconv" "strings" @@ -38,9 +39,13 @@ func handleHistoryPage(w http.ResponseWriter, r *http.Request) { } data := struct { - Entries []HistoryEntryInfo + Entries []HistoryEntryInfo + BasePath string + AppName string }{ - Entries: historyEntries, + Entries: historyEntries, + BasePath: getBasePath(), + AppName: config.AppConfig.AppName, } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -54,6 +59,11 @@ func readHistoryEntries() ([]HistoryEntryInfo, error) { return nil, err } + // Сортируем записи по времени в убывающем порядке (новые сначала) + sort.Slice(entries, func(i, j int) bool { + return entries[i].Timestamp.After(entries[j].Timestamp) + }) + var result []HistoryEntryInfo for _, entry := range entries { result = append(result, HistoryEntryInfo{ @@ -74,7 +84,15 @@ func handleDeleteHistoryEntry(w http.ResponseWriter, r *http.Request) { return } - indexStr := strings.TrimPrefix(r.URL.Path, "/history/delete/") + // Убираем BasePath из URL перед извлечением индекса + basePath := config.AppConfig.Server.BasePath + var indexStr string + if basePath != "" && basePath != "/" { + basePath = strings.TrimSuffix(basePath, "/") + indexStr = strings.TrimPrefix(r.URL.Path, basePath+"/history/delete/") + } else { + indexStr = strings.TrimPrefix(r.URL.Path, "/history/delete/") + } index, err := strconv.Atoi(indexStr) if err != nil { http.Error(w, "Invalid index", http.StatusBadRequest) @@ -110,8 +128,15 @@ func handleClearHistory(w http.ResponseWriter, r *http.Request) { // handleHistoryView обрабатывает просмотр записи истории func handleHistoryView(w http.ResponseWriter, r *http.Request) { - // Получаем индекс из URL - indexStr := strings.TrimPrefix(r.URL.Path, "/history/view/") + // Получаем индекс из URL, учитывая BasePath + basePath := config.AppConfig.Server.BasePath + var indexStr string + if basePath != "" && basePath != "/" { + basePath = strings.TrimSuffix(basePath, "/") + indexStr = strings.TrimPrefix(r.URL.Path, basePath+"/history/view/") + } else { + indexStr = strings.TrimPrefix(r.URL.Path, "/history/view/") + } index, err := strconv.Atoi(indexStr) if err != nil { http.NotFound(w, r) @@ -158,12 +183,14 @@ func handleHistoryView(w http.ResponseWriter, r *http.Request) { Command string Response string ExplanationHTML template.HTML + BasePath string }{ Index: index, Timestamp: targetEntry.Timestamp.Format("02.01.2006 15:04:05"), Command: targetEntry.Command, Response: targetEntry.Response, ExplanationHTML: template.HTML(explanationSection), + BasePath: getBasePath(), } // Парсим и выполняем шаблон diff --git a/serve/history_utils.go b/serve/history_utils.go index 71a99b3..fa6faaf 100644 --- a/serve/history_utils.go +++ b/serve/history_utils.go @@ -21,9 +21,20 @@ type HistoryEntry struct { // read читает записи истории из файла func Read(historyPath string) ([]HistoryEntry, error) { data, err := os.ReadFile(historyPath) - if err != nil || len(data) == 0 { + if err != nil { + // Если файл не существует, создаем пустой файл истории + if os.IsNotExist(err) { + emptyHistory := []HistoryEntry{} + if writeErr := Write(historyPath, emptyHistory); writeErr != nil { + return nil, fmt.Errorf("не удалось создать файл истории: %v", writeErr) + } + return emptyHistory, nil + } return nil, err } + if len(data) == 0 { + return []HistoryEntry{}, nil + } var items []HistoryEntry if err := json.Unmarshal(data, &items); err != nil { return nil, err diff --git a/serve/login.go b/serve/login.go new file mode 100644 index 0000000..a876509 --- /dev/null +++ b/serve/login.go @@ -0,0 +1,103 @@ +package serve + +import ( + "crypto/sha256" + "encoding/hex" + "html/template" + "net/http" + + "github.com/direct-dev-ru/linux-command-gpt/config" + "github.com/direct-dev-ru/linux-command-gpt/serve/templates" +) + +// handleLoginPage обрабатывает страницу входа +func handleLoginPage(w http.ResponseWriter, r *http.Request) { + // Если пользователь уже авторизован, перенаправляем на главную + if isAuthenticated(r) { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + // Генерируем CSRF токен + csrfManager := GetCSRFManager() + if csrfManager == nil { + http.Error(w, "CSRF manager not initialized", http.StatusInternalServerError) + return + } + + // Для неавторизованных пользователей используем сессионный ID + sessionID := getSessionID(r) + csrfToken, err := csrfManager.GenerateToken(sessionID) + if err != nil { + http.Error(w, "Failed to generate CSRF token", http.StatusInternalServerError) + return + } + + // Устанавливаем CSRF токен в cookie + setCSRFCookie(w, csrfToken) + + data := LoginPageData{ + Title: "Авторизация - LCG", + Message: "", + Error: "", + CSRFToken: csrfToken, + } + + if err := RenderLoginPage(w, data); err != nil { + http.Error(w, "Failed to render login page", http.StatusInternalServerError) + return + } +} + +// isAuthenticated проверяет, авторизован ли пользователь +func isAuthenticated(r *http.Request) bool { + // Проверяем, требуется ли аутентификация + if !config.AppConfig.Server.RequireAuth { + return true + } + + // Получаем токен из cookie + token, err := getTokenFromCookie(r) + if err != nil { + return false + } + + // Проверяем валидность токена + _, err = validateJWTToken(token) + return err == nil +} + +// LoginPageData представляет данные для страницы входа +type LoginPageData struct { + Title string + Message string + Error string + CSRFToken string +} + +// RenderLoginPage рендерит страницу входа +func RenderLoginPage(w http.ResponseWriter, data LoginPageData) error { + tmpl, err := template.New("login").Parse(templates.LoginPageTemplate) + if err != nil { + return err + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + return tmpl.Execute(w, data) +} + +// getSessionID получает или создает сессионный ID для пользователя +func getSessionID(r *http.Request) string { + // Пытаемся получить из cookie + if cookie, err := r.Cookie("session_id"); err == nil { + return cookie.Value + } + + // Если нет cookie, генерируем новый ID на основе IP и User-Agent + ip := r.RemoteAddr + userAgent := r.Header.Get("User-Agent") + + // Создаем простой хеш для сессии + hash := sha256.Sum256([]byte(ip + userAgent)) + return hex.EncodeToString(hash[:])[:16] +} diff --git a/serve/middleware.go b/serve/middleware.go new file mode 100644 index 0000000..06dda10 --- /dev/null +++ b/serve/middleware.go @@ -0,0 +1,112 @@ +package serve + +import ( + "net/http" + + "github.com/direct-dev-ru/linux-command-gpt/config" +) + +// AuthMiddleware проверяет аутентификацию для всех запросов +func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Проверяем, требуется ли аутентификация + if !config.AppConfig.Server.RequireAuth { + next(w, r) + return + } + + // Исключаем страницу входа и API логина из проверки + if r.URL.Path == "/login" || r.URL.Path == "/api/login" || r.URL.Path == "/api/validate-token" { + next(w, r) + return + } + + // Проверяем аутентификацию + if !isAuthenticated(r) { + // Для API запросов возвращаем JSON ошибку + if isAPIRequest(r) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"success": false, "error": "Authentication required"}`)) + return + } + + // Для веб-запросов перенаправляем на страницу входа + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + // Пользователь аутентифицирован, продолжаем + next(w, r) + } +} + +// CSRFMiddleware проверяет CSRF токены для POST/PUT/DELETE запросов +func CSRFMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Проверяем только изменяющие запросы + if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" { + next(w, r) + return + } + + // Исключаем некоторые API endpoints + if r.URL.Path == "/api/login" || r.URL.Path == "/api/logout" { + next(w, r) + return + } + + // Получаем CSRF токен из заголовка или формы + csrfToken := r.Header.Get("X-CSRF-Token") + if csrfToken == "" { + csrfToken = r.FormValue("csrf_token") + } + + if csrfToken == "" { + // Для API запросов возвращаем JSON ошибку + if isAPIRequest(r) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"success": false, "error": "CSRF token required"}`)) + return + } + + // Для веб-запросов возвращаем ошибку + http.Error(w, "CSRF token required", http.StatusForbidden) + return + } + + // Получаем сессионный ID + sessionID := getSessionID(r) + + // Проверяем CSRF токен + csrfManager := GetCSRFManager() + if csrfManager == nil || !csrfManager.ValidateToken(csrfToken, sessionID) { + // Для API запросов возвращаем JSON ошибку + if isAPIRequest(r) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"success": false, "error": "Invalid CSRF token"}`)) + return + } + + // Для веб-запросов возвращаем ошибку + http.Error(w, "Invalid CSRF token", http.StatusForbidden) + return + } + + // CSRF токен валиден, продолжаем + next(w, r) + } +} + +// isAPIRequest проверяет, является ли запрос API запросом +func isAPIRequest(r *http.Request) bool { + path := r.URL.Path + return len(path) >= 4 && path[:4] == "/api" +} + +// RequireAuth обертка для requireAuth из auth.go +func RequireAuth(next http.HandlerFunc) http.HandlerFunc { + return requireAuth(next) +} diff --git a/serve/prompts.go b/serve/prompts.go index 9537601..f76b96d 100644 --- a/serve/prompts.go +++ b/serve/prompts.go @@ -90,6 +90,8 @@ func handlePromptsPage(w http.ResponseWriter, r *http.Request) { MaxSystemPromptLength int MaxPromptNameLength int MaxPromptDescLength int + BasePath string + AppName string }{ Prompts: promptsWithDefault, VerbosePrompts: verbosePrompts, @@ -97,6 +99,8 @@ func handlePromptsPage(w http.ResponseWriter, r *http.Request) { MaxSystemPromptLength: config.AppConfig.Validation.MaxSystemPromptLength, MaxPromptNameLength: config.AppConfig.Validation.MaxPromptNameLength, MaxPromptDescLength: config.AppConfig.Validation.MaxPromptDescLength, + BasePath: getBasePath(), + AppName: config.AppConfig.AppName, } w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/serve/results.go b/serve/results.go index 8766a5c..e4cb39f 100644 --- a/serve/results.go +++ b/serve/results.go @@ -8,18 +8,41 @@ import ( "path/filepath" "strings" "time" + "unicode" "github.com/direct-dev-ru/linux-command-gpt/config" "github.com/direct-dev-ru/linux-command-gpt/serve/templates" "github.com/russross/blackfriday/v2" ) +// generateAbbreviation создает аббревиатуру из первых букв слов в названии приложения +func generateAbbreviation(appName string) string { + words := strings.Fields(appName) + var abbreviation strings.Builder + + for _, word := range words { + if len(word) > 0 { + // Берем первую букву слова, если это буква + firstRune := []rune(word)[0] + if unicode.IsLetter(firstRune) { + abbreviation.WriteRune(unicode.ToUpper(firstRune)) + } + } + } + + result := abbreviation.String() + if result == "" { + return "LCG" // Fallback если не удалось сгенерировать аббревиатуру + } + return result +} + // FileInfo содержит информацию о файле type FileInfo struct { Name string Size string ModTime string - Preview string + Preview template.HTML Content string // Полное содержимое для поиска } @@ -52,13 +75,19 @@ func handleResultsPage(w http.ResponseWriter, r *http.Request) { } data := struct { - Files []FileInfo - TotalFiles int - RecentFiles int + Files []FileInfo + TotalFiles int + RecentFiles int + BasePath string + AppName string + AppAbbreviation string }{ - Files: files, - TotalFiles: len(files), - RecentFiles: recentCount, + Files: files, + TotalFiles: len(files), + RecentFiles: recentCount, + BasePath: getBasePath(), + AppName: config.AppConfig.AppName, + AppAbbreviation: generateAbbreviation(config.AppConfig.AppName), } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -83,44 +112,15 @@ func getResultFiles() ([]FileInfo, error) { continue } - // Читаем превью файла (первые 200 символов) и конвертируем Markdown + // Читаем превью файла (первые 200 символов) как обычный текст preview := "" fullContent := "" if content, err := os.ReadFile(filepath.Join(config.AppConfig.ResultFolder, entry.Name())); err == nil { // Сохраняем полное содержимое для поиска fullContent = string(content) - // Конвертируем Markdown в HTML для превью - htmlContent := blackfriday.Run(content) - preview = strings.TrimSpace(string(htmlContent)) - // Удаляем HTML теги для превью - preview = strings.ReplaceAll(preview, "

", "") - preview = strings.ReplaceAll(preview, "

", "") - preview = strings.ReplaceAll(preview, "

", "") - preview = strings.ReplaceAll(preview, "

", "") - preview = strings.ReplaceAll(preview, "

", "") - preview = strings.ReplaceAll(preview, "

", "") - preview = strings.ReplaceAll(preview, "

", "") - preview = strings.ReplaceAll(preview, "

", "") - preview = strings.ReplaceAll(preview, "", "") - preview = strings.ReplaceAll(preview, "", "") - preview = strings.ReplaceAll(preview, "
", "")
-			preview = strings.ReplaceAll(preview, "
", "") - preview = strings.ReplaceAll(preview, "", "") - preview = strings.ReplaceAll(preview, "", "") - preview = strings.ReplaceAll(preview, "", "") - preview = strings.ReplaceAll(preview, "", "") - preview = strings.ReplaceAll(preview, "
    ", "") - preview = strings.ReplaceAll(preview, "
", "") - preview = strings.ReplaceAll(preview, "
  • ", "• ") - preview = strings.ReplaceAll(preview, "
  • ", "") - preview = strings.ReplaceAll(preview, "
      ", "") - preview = strings.ReplaceAll(preview, "
    ", "") - preview = strings.ReplaceAll(preview, "
    ", "") - preview = strings.ReplaceAll(preview, "
    ", "") - preview = strings.ReplaceAll(preview, "
    ", "") - preview = strings.ReplaceAll(preview, "
    ", "") - preview = strings.ReplaceAll(preview, "
    ", "") + // Берем первые 200 символов как превью + preview = string(content) // Очищаем от лишних пробелов и переносов preview = strings.ReplaceAll(preview, "\n", " ") preview = strings.ReplaceAll(preview, "\r", "") @@ -136,7 +136,7 @@ func getResultFiles() ([]FileInfo, error) { Name: entry.Name(), Size: formatFileSize(info.Size()), ModTime: info.ModTime().Format("02.01.2006 15:04"), - Preview: preview, + Preview: template.HTML(preview), Content: fullContent, }) } diff --git a/serve/serve.go b/serve/serve.go index 6b85e70..cb84a29 100644 --- a/serve/serve.go +++ b/serve/serve.go @@ -5,13 +5,49 @@ import ( "fmt" "net/http" "os" + "strings" "github.com/direct-dev-ru/linux-command-gpt/config" "github.com/direct-dev-ru/linux-command-gpt/ssl" ) +// makePath создает путь с учетом BasePath +func makePath(path string) string { + basePath := config.AppConfig.Server.BasePath + if basePath == "" || basePath == "/" { + return path + } + + // Убираем слэш в конце basePath если есть + basePath = strings.TrimSuffix(basePath, "/") + + // Убираем слэш в начале path если есть + path = strings.TrimPrefix(path, "/") + + // Если path пустой, возвращаем basePath с слэшем в конце + if path == "" { + return basePath + "/" + } + + return basePath + "/" + path +} + +// getBasePath возвращает BasePath для использования в шаблонах +func getBasePath() string { + basePath := config.AppConfig.Server.BasePath + if basePath == "" || basePath == "/" { + return "" + } + return strings.TrimSuffix(basePath, "/") +} + // StartResultServer запускает HTTP/HTTPS сервер для просмотра сохраненных результатов func StartResultServer(host, port string) error { + // Инициализируем CSRF менеджер + if err := InitCSRFManager(); err != nil { + return fmt.Errorf("failed to initialize CSRF manager: %v", err) + } + addr := fmt.Sprintf("%s:%s", host, port) // Проверяем, нужно ли использовать HTTPS @@ -103,78 +139,116 @@ func registerHTTPSRoutes() { registerRoutesExceptHome() // Регистрируем главную страницу с проверкой HTTPS - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.HandleFunc(makePath("/"), func(w http.ResponseWriter, r *http.Request) { // Проверяем, пришел ли запрос по HTTP (не HTTPS) if r.TLS == nil { handleHTTPSRedirect(w, r) return } // Если уже HTTPS, обрабатываем как обычно - handleResultsPage(w, r) + AuthMiddleware(handleResultsPage)(w, r) }) + + // Регистрируем главную страницу без слэша в конце для BasePath + basePath := config.AppConfig.Server.BasePath + if basePath != "" && basePath != "/" { + basePath = strings.TrimSuffix(basePath, "/") + http.HandleFunc(basePath, func(w http.ResponseWriter, r *http.Request) { + // Проверяем, пришел ли запрос по HTTP (не HTTPS) + if r.TLS == nil { + handleHTTPSRedirect(w, r) + return + } + // Если уже HTTPS, обрабатываем как обычно + AuthMiddleware(handleResultsPage)(w, r) + }) + } } // registerRoutesExceptHome регистрирует все маршруты кроме главной страницы func registerRoutesExceptHome() { + // Страница входа (без аутентификации) + http.HandleFunc(makePath("/login"), handleLoginPage) + + // API для аутентификации (без аутентификации) + http.HandleFunc(makePath("/api/login"), handleLogin) + http.HandleFunc(makePath("/api/logout"), handleLogout) + http.HandleFunc(makePath("/api/validate-token"), handleValidateToken) + // Файлы - http.HandleFunc("/file/", handleFileView) - http.HandleFunc("/delete/", handleDeleteFile) + http.HandleFunc(makePath("/file/"), AuthMiddleware(handleFileView)) + http.HandleFunc(makePath("/delete/"), AuthMiddleware(handleDeleteFile)) // История запросов - http.HandleFunc("/history", handleHistoryPage) - http.HandleFunc("/history/view/", handleHistoryView) - http.HandleFunc("/history/delete/", handleDeleteHistoryEntry) - http.HandleFunc("/history/clear", handleClearHistory) + http.HandleFunc(makePath("/history"), AuthMiddleware(handleHistoryPage)) + http.HandleFunc(makePath("/history/view/"), AuthMiddleware(handleHistoryView)) + http.HandleFunc(makePath("/history/delete/"), AuthMiddleware(handleDeleteHistoryEntry)) + http.HandleFunc(makePath("/history/clear"), AuthMiddleware(handleClearHistory)) // Управление промптами - http.HandleFunc("/prompts", handlePromptsPage) - http.HandleFunc("/prompts/add", handleAddPrompt) - http.HandleFunc("/prompts/edit/", handleEditPrompt) - http.HandleFunc("/prompts/edit-verbose/", handleEditVerbosePrompt) - http.HandleFunc("/prompts/delete/", handleDeletePrompt) - http.HandleFunc("/prompts/restore/", handleRestorePrompt) - http.HandleFunc("/prompts/restore-verbose/", handleRestoreVerbosePrompt) - http.HandleFunc("/prompts/save-lang", handleSaveLang) + http.HandleFunc(makePath("/prompts"), AuthMiddleware(handlePromptsPage)) + http.HandleFunc(makePath("/prompts/add"), AuthMiddleware(handleAddPrompt)) + http.HandleFunc(makePath("/prompts/edit/"), AuthMiddleware(handleEditPrompt)) + http.HandleFunc(makePath("/prompts/edit-verbose/"), AuthMiddleware(handleEditVerbosePrompt)) + http.HandleFunc(makePath("/prompts/delete/"), AuthMiddleware(handleDeletePrompt)) + http.HandleFunc(makePath("/prompts/restore/"), AuthMiddleware(handleRestorePrompt)) + http.HandleFunc(makePath("/prompts/restore-verbose/"), AuthMiddleware(handleRestoreVerbosePrompt)) + http.HandleFunc(makePath("/prompts/save-lang"), AuthMiddleware(handleSaveLang)) // Веб-страница для выполнения запросов - http.HandleFunc("/run", handleExecutePage) + http.HandleFunc(makePath("/run"), AuthMiddleware(CSRFMiddleware(handleExecutePage))) // API для выполнения запросов - http.HandleFunc("/api/execute", handleExecute) + http.HandleFunc(makePath("/api/execute"), AuthMiddleware(CSRFMiddleware(handleExecute))) // API для сохранения результатов и истории - http.HandleFunc("/api/save-result", handleSaveResult) - http.HandleFunc("/api/add-to-history", handleAddToHistory) + http.HandleFunc(makePath("/api/save-result"), AuthMiddleware(CSRFMiddleware(handleSaveResult))) + http.HandleFunc(makePath("/api/add-to-history"), AuthMiddleware(CSRFMiddleware(handleAddToHistory))) } // registerRoutes регистрирует все маршруты сервера func registerRoutes() { + // Страница входа (без аутентификации) + http.HandleFunc(makePath("/login"), handleLoginPage) + + // API для аутентификации (без аутентификации) + http.HandleFunc(makePath("/api/login"), handleLogin) + http.HandleFunc(makePath("/api/logout"), handleLogout) + http.HandleFunc(makePath("/api/validate-token"), handleValidateToken) + // Главная страница и файлы - http.HandleFunc("/", handleResultsPage) - http.HandleFunc("/file/", handleFileView) - http.HandleFunc("/delete/", handleDeleteFile) + http.HandleFunc(makePath("/"), AuthMiddleware(handleResultsPage)) + http.HandleFunc(makePath("/file/"), AuthMiddleware(handleFileView)) + http.HandleFunc(makePath("/delete/"), AuthMiddleware(handleDeleteFile)) // История запросов - http.HandleFunc("/history", handleHistoryPage) - http.HandleFunc("/history/view/", handleHistoryView) - http.HandleFunc("/history/delete/", handleDeleteHistoryEntry) - http.HandleFunc("/history/clear", handleClearHistory) + http.HandleFunc(makePath("/history"), AuthMiddleware(handleHistoryPage)) + http.HandleFunc(makePath("/history/view/"), AuthMiddleware(handleHistoryView)) + http.HandleFunc(makePath("/history/delete/"), AuthMiddleware(handleDeleteHistoryEntry)) + http.HandleFunc(makePath("/history/clear"), AuthMiddleware(handleClearHistory)) // Управление промптами - http.HandleFunc("/prompts", handlePromptsPage) - http.HandleFunc("/prompts/add", handleAddPrompt) - http.HandleFunc("/prompts/edit/", handleEditPrompt) - http.HandleFunc("/prompts/edit-verbose/", handleEditVerbosePrompt) - http.HandleFunc("/prompts/delete/", handleDeletePrompt) - http.HandleFunc("/prompts/restore/", handleRestorePrompt) - http.HandleFunc("/prompts/restore-verbose/", handleRestoreVerbosePrompt) - http.HandleFunc("/prompts/save-lang", handleSaveLang) + http.HandleFunc(makePath("/prompts"), AuthMiddleware(handlePromptsPage)) + http.HandleFunc(makePath("/prompts/add"), AuthMiddleware(handleAddPrompt)) + http.HandleFunc(makePath("/prompts/edit/"), AuthMiddleware(handleEditPrompt)) + http.HandleFunc(makePath("/prompts/edit-verbose/"), AuthMiddleware(handleEditVerbosePrompt)) + http.HandleFunc(makePath("/prompts/delete/"), AuthMiddleware(handleDeletePrompt)) + http.HandleFunc(makePath("/prompts/restore/"), AuthMiddleware(handleRestorePrompt)) + http.HandleFunc(makePath("/prompts/restore-verbose/"), AuthMiddleware(handleRestoreVerbosePrompt)) + http.HandleFunc(makePath("/prompts/save-lang"), AuthMiddleware(handleSaveLang)) // Веб-страница для выполнения запросов - http.HandleFunc("/run", handleExecutePage) + http.HandleFunc(makePath("/run"), AuthMiddleware(CSRFMiddleware(handleExecutePage))) // API для выполнения запросов - http.HandleFunc("/api/execute", handleExecute) + http.HandleFunc(makePath("/api/execute"), AuthMiddleware(CSRFMiddleware(handleExecute))) // API для сохранения результатов и истории - http.HandleFunc("/api/save-result", handleSaveResult) - http.HandleFunc("/api/add-to-history", handleAddToHistory) + http.HandleFunc(makePath("/api/save-result"), AuthMiddleware(CSRFMiddleware(handleSaveResult))) + http.HandleFunc(makePath("/api/add-to-history"), AuthMiddleware(CSRFMiddleware(handleAddToHistory))) + + // Регистрируем главную страницу без слэша в конце для BasePath + basePath := config.AppConfig.Server.BasePath + if basePath != "" && basePath != "/" { + basePath = strings.TrimSuffix(basePath, "/") + http.HandleFunc(basePath, AuthMiddleware(handleResultsPage)) + } } diff --git a/serve/templates/execute.css.go b/serve/templates/execute.css.go index 1eb627f..a9d35cc 100644 --- a/serve/templates/execute.css.go +++ b/serve/templates/execute.css.go @@ -79,6 +79,14 @@ var ExecutePageCSSTemplate = template.Must(template.New("execute_css").Parse(` opacity: 0.9; font-size: 1.1em; } + + .config-info { + margin: 5px 0 0 0 !important; + opacity: 0.7 !important; + font-size: 0.9em !important; + font-style: italic; + color: rgba(255, 255, 255, 0.8); + } .content { padding: 30px; } diff --git a/serve/templates/execute.go b/serve/templates/execute.go index b610061..7e11ca1 100644 --- a/serve/templates/execute.go +++ b/serve/templates/execute.go @@ -8,7 +8,7 @@ var ExecutePageTemplate = template.Must(template.New("execute").Parse(` - {{.Title}} - Linux Command GPT + {{.Title}} - {{.AppName}} @@ -17,16 +17,19 @@ var ExecutePageTemplate = template.Must(template.New("execute").Parse(`

    {{.Header}}

    -

    Выполнение запросов к Linux Command GPT через веб-интерфейс

    +

    Выполнение запросов к {{.AppName}} через веб-интерфейс

    +

    ({{.ProviderType}} • {{.Model}} • {{.Host}})

    + +
    diff --git a/serve/templates/execute.js.go b/serve/templates/execute.js.go index feccbb2..5c2cd87 100644 --- a/serve/templates/execute.js.go +++ b/serve/templates/execute.js.go @@ -89,6 +89,7 @@ var ExecutePageScriptsTemplate = template.Must(template.New("execute_scripts").P function saveResult() { const resultDataField = document.getElementById('resultData'); const prompt = document.getElementById('prompt').value; + const csrfToken = document.querySelector('input[name="csrf_token"]').value; if (!resultDataField.value || !prompt.trim()) { alert('Нет данных для сохранения'); @@ -104,10 +105,11 @@ var ExecutePageScriptsTemplate = template.Must(template.New("execute_scripts").P model: resultData.model || 'Unknown' }; - fetch('/api/save-result', { + fetch('{{.BasePath}}/api/save-result', { method: 'POST', headers: { 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, }, body: JSON.stringify(requestData) }) @@ -134,6 +136,7 @@ var ExecutePageScriptsTemplate = template.Must(template.New("execute_scripts").P const resultDataField = document.getElementById('resultData'); const prompt = document.getElementById('prompt').value; const systemId = document.getElementById('system_id').value; + const csrfToken = document.querySelector('input[name="csrf_token"]').value; if (!resultDataField.value || !prompt.trim()) { alert('Нет данных для сохранения в историю'); @@ -152,10 +155,11 @@ var ExecutePageScriptsTemplate = template.Must(template.New("execute_scripts").P system: systemName }; - fetch('/api/add-to-history', { + fetch('{{.BasePath}}/api/add-to-history', { method: 'POST', headers: { 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, }, body: JSON.stringify(requestData) }) diff --git a/serve/templates/history.go b/serve/templates/history.go index 7c288e4..07524e9 100644 --- a/serve/templates/history.go +++ b/serve/templates/history.go @@ -155,13 +155,13 @@ const HistoryPageTemplate = `

    📝 История запросов

    -

    Управление историей запросов Linux Command GPT

    +

    Управление историей запросов {{.AppName}}

    @@ -197,12 +197,12 @@ const HistoryPageTemplate = ` + +` diff --git a/serve/templates/prompts.go b/serve/templates/prompts.go index 0bd5c46..c59cbdd 100644 --- a/serve/templates/prompts.go +++ b/serve/templates/prompts.go @@ -235,13 +235,13 @@ const PromptsPageTemplate = `

    ⚙️ Системные промпты

    -

    Управление системными промптами Linux Command GPT

    +

    Управление системными промптами {{.AppName}}