diff --git a/.gitignore b/.gitignore index 80c8ca3..02d5e05 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Binaries +VERSION.txt certs/ go-speech +go-speech*.tar.gz +go-speech*.tar models/ *.exe *.exe~ diff --git a/Dockerfile b/Dockerfile index 6fe57fd..5d21725 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,31 +16,31 @@ COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o go-speech . # Этап 2: Финальный образ с зависимостями -FROM alpine:latest - -# RUN curl -L https://github.com/rhasspy/piper/releases/download/v1.2.0/piper_amd64.tar.gz -o /tmp/piper.tar.gz +ARG BASE_IMAGE +FROM ${BASE_IMAGE} COPY piper.tar.gz /tmp/piper.tar.gz # Установка необходимых пакетов -RUN apk add --no-cache \ +RUN apt-get update && \ + apt-get install -y \ ca-certificates \ ffmpeg \ curl \ bash \ - libc6-compat \ - libstdc++ + libstdc++6 \ + tar \ + gzip && \ + rm -rf /var/lib/apt/lists/* # Установка Piper TTS - RUN mkdir -p /opt/piper && \ cd /opt/piper && \ tar -xzf /tmp/piper.tar.gz && \ PIPER_BIN=$(find /opt/piper -name "piper" -type f | head -1) && \ chmod +x $PIPER_BIN && \ find /opt/piper -name "*.so*" -type f -exec chmod +x {} \; && \ - ln -sf $PIPER_BIN /usr/local/bin/piper && \ - rm -f /tmp/piper.tar.gz + ln -sf $PIPER_BIN /usr/local/bin/piper # Создание директорий RUN mkdir -p /app/models /app/certs /app/tmp @@ -50,10 +50,6 @@ COPY models/ /app/models/ # Копирование бинарника из builder COPY --from=builder /build/go-speech /app/go-speech -# Примечание: Модели должны быть смонтированы через volume при запуске контейнера -# Пример: -v $(pwd)/models:/app/models:ro -# Или скопированы в образ на этапе сборки, если они включены в репозиторий - # Рабочая директория WORKDIR /app diff --git a/Dockerfile copy b/Dockerfile copy new file mode 100644 index 0000000..8555ef6 --- /dev/null +++ b/Dockerfile copy @@ -0,0 +1,77 @@ +# Multi-stage build для оптимизации размера образа + +# Этап 1: Сборка Go приложения +FROM golang:1.25-alpine AS builder + +WORKDIR /build + +# Копирование go mod файлов +COPY go.mod ./ +RUN go mod download + +# Копирование исходного кода +COPY . . + +# Сборка приложения +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o go-speech . + +# Этап 2: Финальный образ с зависимостями +FROM alpine:latest + +# RUN curl -L https://github.com/rhasspy/piper/releases/download/v1.2.0/piper_amd64.tar.gz -o /tmp/piper.tar.gz + +COPY piper.tar.gz /tmp/piper.tar.gz + +# Установка необходимых пакетов +RUN apk add --no-cache \ + ca-certificates \ + ffmpeg \ + curl \ + bash \ + libc6-compat \ + libstdc++ \ + rm -rf /var/lib/apt/lists/* + +# Установка Piper TTS + +RUN mkdir -p /opt/piper && \ + cd /opt/piper && \ + tar -xzf /tmp/piper.tar.gz && \ + PIPER_BIN=$(find /opt/piper -name "piper" -type f | head -1) && \ + chmod +x $PIPER_BIN && \ + find /opt/piper -name "*.so*" -type f -exec chmod +x {} \; && \ + ln -sf $PIPER_BIN /usr/local/bin/piper && \ + rm -f /tmp/piper.tar.gz + +# Создание директорий +RUN mkdir -p /app/models /app/certs /app/tmp + +COPY models/ /app/models/ + +# Копирование бинарника из builder +COPY --from=builder /build/go-speech /app/go-speech + +# Примечание: Модели должны быть смонтированы через volume при запуске контейнера +# Пример: -v $(pwd)/models:/app/models:ro +# Или скопированы в образ на этапе сборки, если они включены в репозиторий + +# Рабочая директория +WORKDIR /app + +# Переменные окружения по умолчанию +ENV PORT=8443 +ENV PIPER_PATH=/usr/local/bin/piper +ENV PIPER_BIN_PATH=/opt/piper +ENV MODEL_DIR=/app/models +ENV GO_SPEECH_VOICE=ruslan +ENV FFMPEG_PATH=/usr/bin/ffmpeg +ENV CERT_FILE=/app/certs/server.crt +ENV KEY_FILE=/app/certs/server.key +ENV LD_LIBRARY_PATH=/opt/piper:/usr/lib:/usr/local/lib:${LD_LIBRARY_PATH} + +# Экспорт порта +EXPOSE 8443 + +# Запуск приложения +CMD ["./go-speech"] + diff --git a/README.md b/README.md index 77bd55d..c2b5ed5 100644 --- a/README.md +++ b/README.md @@ -64,37 +64,110 @@ sudo apt-get install ffmpeg apk add ffmpeg ``` -6 Сгенерируйте SSL сертификаты: +6 (Опционально) Сгенерируйте SSL сертификаты вручную: ```bash ./generate-certs.sh ``` +**Примечание:** Если вы используете HTTPS (`GO_SPEECH_TLS=true`), сертификаты будут автоматически сгенерированы при первом запуске, если они отсутствуют. + 7 Запустите сервис: +**Запуск в режиме HTTP (по умолчанию):** + ```bash +# Использует порт 8080 по умолчанию go run main.go ``` -Или с переменными окружения: +**Запуск в режиме HTTP с указанием порта:** + +```bash +GO_SPEECH_PORT=3000 go run main.go +``` + +**Запуск в режиме HTTPS с автогенерацией сертификатов:** + +```bash +GO_SPEECH_TLS=true \ +GO_SPEECH_PORT=8443 \ +GO_SPEECH_VOICE=ruslan \ +go run main.go +``` + +**Запуск в режиме HTTPS с указанием доменов для сертификата:** + +```bash +GO_SPEECH_TLS=true \ +GO_SPEECH_PORT=8443 \ +GO_SPEECH_TLS_DOMAINS="example.com,api.example.com" \ +GO_SPEECH_VOICE=ruslan \ +go run main.go +``` + +**Запуск в режиме HTTPS с использованием CA сертификата:** + +```bash +GO_SPEECH_TLS=true \ +GO_SPEECH_PORT=8443 \ +GO_SPEECH_CA_CERT=./ca/ca.crt \ +GO_SPEECH_TLS_DOMAINS="example.com" \ +GO_SPEECH_VOICE=ruslan \ +go run main.go +``` + +**Запуск с разными голосами:** + +```bash +# Голос Irina (женский) +GO_SPEECH_VOICE=irina go run main.go + +# Голос Denis (мужской) +GO_SPEECH_VOICE=denis go run main.go + +# Голос Dmitri (мужской) +GO_SPEECH_VOICE=dmitri go run main.go +``` + +**Запуск с указанием конкретной модели через MODEL_PATH:** + +```bash +GO_SPEECH_VOICE=ruslan \ +MODEL_PATH=/path/to/custom/ru_RU-ruslan-medium.onnx \ +go run main.go +``` + +**Запуск в режиме продакшена (минимальное логирование):** + +```bash +GO_SPEECH_MODE=release go run main.go +``` + +**Запуск с кастомными путями к утилитам:** + +```bash +GO_SPEECH_PIPER_PATH=/opt/piper/bin/piper \ +GO_SPEECH_FFMPEG_PATH=/opt/ffmpeg/bin/ffmpeg \ +GO_SPEECH_MODEL_DIR=/data/models \ +go run main.go +``` + +**Полный пример с переменными окружения:** ```bash GO_SPEECH_PORT=8443 \ +GO_SPEECH_TLS=true \ GO_SPEECH_PIPER_PATH=/usr/local/bin/piper \ GO_SPEECH_MODEL_DIR=./models \ GO_SPEECH_VOICE=ruslan \ GO_SPEECH_FFMPEG_PATH=/usr/bin/ffmpeg \ GO_SPEECH_CERT_FILE=./certs/server.crt \ GO_SPEECH_KEY_FILE=./certs/server.key \ +GO_SPEECH_MODE=debug \ go run main.go ``` -Для использования другого голоса (например, `denis`, `dmitri`, `irina`): - -```bash -GO_SPEECH_VOICE=denis go run main.go -``` - ### Запуск через Podman/Docker 1 Соберите образ: @@ -111,31 +184,117 @@ curl -L https://huggingface.co/rhasspy/piper-voices/resolve/main/ru/ru_RU/denis/ curl -L https://huggingface.co/rhasspy/piper-voices/resolve/main/ru/ru_RU/denis/medium/ru_RU-denis-medium.onnx.json -o models/ru_RU-denis-medium.onnx.json ``` -3 Сгенерируйте сертификаты: +3 (Опционально) Сгенерируйте сертификаты вручную: ```bash ./generate-certs.sh ``` +**Примечание:** При использовании `GO_SPEECH_TLS=true` сертификаты будут автоматически сгенерированы при первом запуске контейнера. + 4 Запустите контейнер: +**Запуск в режиме HTTP:** + +```bash +podman run -d \ + --name go-speech \ + -p 8080:8080 \ + -e GO_SPEECH_PORT=8080 \ + -e GO_SPEECH_VOICE=ruslan \ + -v $(pwd)/models:/app/models:ro \ + go-speech:latest +``` + +**Запуск в режиме HTTPS с автогенерацией сертификатов:** + ```bash podman run -d \ --name go-speech \ -p 8443:8443 \ + -e GO_SPEECH_TLS=true \ + -e GO_SPEECH_PORT=8443 \ + -e GO_SPEECH_VOICE=ruslan \ + -e GO_SPEECH_TLS_DOMAINS="example.com,api.example.com" \ + -v $(pwd)/models:/app/models:ro \ + -v $(pwd)/certs:/app/certs \ + go-speech:latest +``` + +**Запуск с предварительно созданными сертификатами:** + +```bash +podman run -d \ + --name go-speech \ + -p 8443:8443 \ + -e GO_SPEECH_TLS=true \ + -e GO_SPEECH_PORT=8443 \ -e GO_SPEECH_VOICE=ruslan \ -v $(pwd)/models:/app/models:ro \ -v $(pwd)/certs:/app/certs:ro \ go-speech:latest ``` -Для использования другого голоса: +**Запуск с разными голосами:** + +```bash +# Голос Irina +podman run -d \ + --name go-speech-irina \ + -p 8443:8443 \ + -e GO_SPEECH_TLS=true \ + -e GO_SPEECH_VOICE=irina \ + -v $(pwd)/models:/app/models:ro \ + -v $(pwd)/certs:/app/certs:ro \ + go-speech:latest + +# Голос Denis +podman run -d \ + --name go-speech-denis \ + -p 8444:8443 \ + -e GO_SPEECH_TLS=true \ + -e GO_SPEECH_VOICE=denis \ + -v $(pwd)/models:/app/models:ro \ + -v $(pwd)/certs:/app/certs:ro \ + go-speech:latest + +# Голос Dmitri +podman run -d \ + --name go-speech-dmitri \ + -p 8445:8443 \ + -e GO_SPEECH_TLS=true \ + -e GO_SPEECH_VOICE=dmitri \ + -v $(pwd)/models:/app/models:ro \ + -v $(pwd)/certs:/app/certs:ro \ + go-speech:latest +``` + +**Запуск в режиме продакшена:** ```bash podman run -d \ --name go-speech \ -p 8443:8443 \ - -e GO_SPEECH_VOICE=denis \ + -e GO_SPEECH_TLS=true \ + -e GO_SPEECH_MODE=release \ + -e GO_SPEECH_VOICE=ruslan \ + -v $(pwd)/models:/app/models:ro \ + -v $(pwd)/certs:/app/certs:ro \ + go-speech:latest +``` + +**Запуск с кастомными путями и портом:** + +```bash +podman run -d \ + --name go-speech \ + -p 9000:9000 \ + -e GO_SPEECH_PORT=9000 \ + -e GO_SPEECH_TLS=true \ + -e GO_SPEECH_PIPER_PATH=/usr/local/bin/piper \ + -e GO_SPEECH_FFMPEG_PATH=/usr/bin/ffmpeg \ + -e GO_SPEECH_MODEL_DIR=/app/models \ + -e GO_SPEECH_VOICE=ruslan \ -v $(pwd)/models:/app/models:ro \ -v $(pwd)/certs:/app/certs:ro \ go-speech:latest @@ -180,20 +339,36 @@ sudo systemctl status go-speech Откройте в браузере: +**Для HTTPS режима:** + ``` text https://localhost:8443/go-speech/front ``` +**Для HTTP режима:** + +``` text +http://localhost:8080/go-speech/front +``` + ### GET /go-speech/help Отображение документации (README.md) в браузере. **Доступ:** +**Для HTTPS режима:** + ``` text https://localhost:8443/go-speech/help ``` +**Для HTTP режима:** + +``` text +http://localhost:8080/go-speech/help +``` + **Возможности:** - Выбор голоса из доступных моделей (Ruslan, Irina, Denis, Dmitri) @@ -281,20 +456,53 @@ curl https://localhost:8443/go-speech/api/v1/health --insecure ## Переменные окружения -- `PORT` - Порт для HTTPS сервера (по умолчанию: 8443) -- `CERT_FILE` - Путь к SSL сертификату (по умолчанию: certs/server.crt) -- `KEY_FILE` - Путь к SSL приватному ключу (по умолчанию: certs/server.key) -- `PIPER_PATH` - Путь к бинарнику Piper TTS (по умолчанию: /usr/local/bin/piper) -- `MODEL_DIR` - Директория с моделями (по умолчанию: models) -- `GO_SPEECH_VOICE` - Имя голоса для синтеза речи (по умолчанию: ruslan) +### Основные настройки + +- `GO_SPEECH_PORT` - Порт для запуска сервера + - По умолчанию: `8443` (если `GO_SPEECH_TLS=true`) или `8080` (если TLS отключен) + - Можно указать любой доступный порт +- `GO_SPEECH_VOICE` - Имя голоса для синтеза речи (по умолчанию: `ruslan`) - Доступные варианты: `ruslan`, `denis`, `dmitri`, `irina` - Путь к модели формируется как: `{MODEL_DIR}/ru_RU-{GO_SPEECH_VOICE}-medium.onnx` -- `MODEL_PATH` - Полный путь к модели Piper TTS (опционально, переопределяет автоматический выбор на основе GO_SPEECH_VOICE) -- `FFMPEG_PATH` - Путь к бинарнику ffmpeg (по умолчанию: /usr/bin/ffmpeg) -- `GO_SPEECH_MODE` - Режим работы сервера (по умолчанию: debug) +- `MODEL_PATH` - Полный путь к модели Piper TTS (опционально) + - Переопределяет автоматический выбор на основе `GO_SPEECH_VOICE` + - Используется, если нужно указать конкретную модель напрямую +- `GO_SPEECH_MODE` - Режим работы сервера (по умолчанию: `debug`) - `release` - Режим продакшена (минимальное логирование) - Любое другое значение - Режим разработки (подробное логирование всех операций) +### Настройки TLS/HTTPS + +- `GO_SPEECH_TLS` - Включение HTTPS режима (по умолчанию: не задано, используется HTTP) + - Если установлено в `true` - сервер запускается по HTTPS + - Если не задано или `false` - сервер запускается по HTTP + - При включенном HTTPS и отсутствии сертификатов - они будут автоматически сгенерированы + +- `GO_SPEECH_CERT_FILE` - Путь к файлу TLS сертификата (по умолчанию: `certs/server.crt`) + - Используется только при `GO_SPEECH_TLS=true` + - Если файл отсутствует и TLS включен - будет автоматически сгенерирован + +- `GO_SPEECH_KEY_FILE` - Путь к файлу приватного ключа (по умолчанию: `certs/server.key`) + - Используется только при `GO_SPEECH_TLS=true` + - Если файл отсутствует и TLS включен - будет автоматически сгенерирован + +- `GO_SPEECH_TLS_DOMAINS` - Список доменов для добавления в сертификат (опционально) + - Формат: домены через запятую, например: `example.com,api.example.com` + - В сертификат всегда автоматически добавляются: `localhost`, `127.0.0.1` + - Используется только при автогенерации сертификатов + +- `GO_SPEECH_CA_CERT` - Путь к CA сертификату для подписи сертификата (опционально) + - Если задан и файл существует - сертификат будет подписан этим CA + - CA ключ должен находиться в том же каталоге с расширением `.key` + - Если CA не найден - генерируется самоподписанный сертификат + - Используется только при автогенерации сертификатов + +### Пути к утилитам + +- `GO_SPEECH_PIPER_PATH` - Путь к исполняемому файлу Piper TTS (по умолчанию: `/usr/local/bin/piper`) +- `GO_SPEECH_FFMPEG_PATH` - Путь к исполняемому файлу ffmpeg (по умолчанию: `/usr/bin/ffmpeg`) +- `GO_SPEECH_MODEL_DIR` - Директория с моделями Piper TTS (по умолчанию: `models`) + ## Кэширование Сервер автоматически кэширует сгенерированные аудио файлы для ускорения обработки повторных запросов. diff --git a/main-funcs.go b/main-funcs.go new file mode 100644 index 0000000..b964455 --- /dev/null +++ b/main-funcs.go @@ -0,0 +1,385 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "go-speech/internal/logger" + "html/template" + "math/big" + "net" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "time" +) + +// generateCertificate генерирует самоподписанный TLS сертификат +// certFile - путь к файлу сертификата +// keyFile - путь к файлу приватного ключа +// caCertFile - путь к CA сертификату (опционально, если задан и существует - используется для подписи) +// domains - список доменов через запятую для добавления в сертификат (всегда добавляются localhost и 127.0.0.1) +func generateCertificate(certFile, keyFile, caCertFile, domains string) error { + // Создаем директорию для сертификатов если не существует + certDir := filepath.Dir(certFile) + if err := os.MkdirAll(certDir, 0755); err != nil { + return fmt.Errorf("не удалось создать директорию для сертификатов: %v", err) + } + + // Парсим домены из переменной окружения + domainList := []string{"localhost", "127.0.0.1"} + if domains != "" { + parts := strings.SplitSeq(domains, ",") + for part := range parts { + part = strings.TrimSpace(part) + if part != "" { + found := slices.Contains(domainList, part) + if !found { + domainList = append(domainList, part) + } + } + } + } + + logger.Debug("Генерация сертификата для доменов: %v", domainList) + + // Генерируем приватный ключ + privateKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return fmt.Errorf("ошибка генерации приватного ключа: %v", err) + } + + // Создаем шаблон сертификата + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return fmt.Errorf("ошибка генерации серийного номера: %v", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Country: []string{"RU"}, + Organization: []string{"direct-dev.ru"}, + OrganizationalUnit: []string{"TTS Service"}, + CommonName: "localhost", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 3 * 24 * time.Hour), // 10 лет + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, + DNSNames: domainList, + } + + // Загружаем CA сертификат и ключ если указаны + var caCert *x509.Certificate + var caKey *rsa.PrivateKey + var parent *x509.Certificate + var signer any + + if caCertFile != "" { + if _, err := os.Stat(caCertFile); err == nil { + logger.Debug("Загрузка CA сертификата из %s", caCertFile) + caCertData, err := os.ReadFile(caCertFile) + if err != nil { + return fmt.Errorf("ошибка чтения CA сертификата: %v", err) + } + + block, _ := pem.Decode(caCertData) + if block == nil { + return fmt.Errorf("не удалось декодировать CA сертификат") + } + + caCert, err = x509.ParseCertificate(block.Bytes) + if err != nil { + return fmt.Errorf("ошибка парсинга CA сертификата: %v", err) + } + + // Ищем CA ключ (ожидается в том же каталоге с расширением .key) + caKeyFile := strings.TrimSuffix(caCertFile, ".crt") + ".key" + if strings.HasSuffix(caCertFile, ".pem") { + caKeyFile = strings.TrimSuffix(caCertFile, ".pem") + ".key" + } + + if _, err := os.Stat(caKeyFile); err == nil { + logger.Debug("Загрузка CA ключа из %s", caKeyFile) + caKeyData, err := os.ReadFile(caKeyFile) + if err != nil { + return fmt.Errorf("ошибка чтения CA ключа: %v", err) + } + + block, _ := pem.Decode(caKeyData) + if block == nil { + return fmt.Errorf("не удалось декодировать CA ключ") + } + + caKey, err = x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + // Пробуем PKCS8 формат + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return fmt.Errorf("ошибка парсинга CA ключа: %v", err) + } + var ok bool + caKey, ok = key.(*rsa.PrivateKey) + if !ok { + return fmt.Errorf("CA ключ не является RSA ключом") + } + } + + parent = caCert + signer = caKey + logger.Info("Используется CA сертификат для подписи") + } else { + logger.Warn("CA ключ не найден (%s), генерируем самоподписанный сертификат", caKeyFile) + } + } else { + logger.Warn("CA сертификат не найден (%s), генерируем самоподписанный сертификат", caCertFile) + } + } + + // Если CA не используется - создаем самоподписанный сертификат + if parent == nil { + parent = &template + signer = privateKey + logger.Info("Генерация самоподписанного сертификата") + } + + // Создаем сертификат + certDER, err := x509.CreateCertificate(rand.Reader, &template, parent, &privateKey.PublicKey, signer) + if err != nil { + return fmt.Errorf("ошибка создания сертификата: %v", err) + } + + // Сохраняем сертификат + certOut, err := os.Create(certFile) + if err != nil { + return fmt.Errorf("ошибка создания файла сертификата: %v", err) + } + defer certOut.Close() + + if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil { + return fmt.Errorf("ошибка записи сертификата: %v", err) + } + + // Сохраняем приватный ключ + keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("ошибка создания файла ключа: %v", err) + } + defer keyOut.Close() + + keyDER := x509.MarshalPKCS1PrivateKey(privateKey) + if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyDER}); err != nil { + return fmt.Errorf("ошибка записи ключа: %v", err) + } + + logger.Info("Сертификат успешно сгенерирован") + return nil +} + +// loggingMiddleware добавляет логирование запросов +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + logger.Debug("Входящий запрос: %s %s от %s", r.Method, r.URL.Path, r.RemoteAddr) + logger.Debug(" User-Agent: %s", r.UserAgent()) + logger.Debug(" Content-Type: %s", r.Header.Get("Content-Type")) + logger.Debug(" Content-Length: %s", r.Header.Get("Content-Length")) + + next.ServeHTTP(w, r) + + duration := time.Since(start) + logger.Info("%s %s %v", r.Method, r.URL.Path, duration) + logger.Debug("Запрос обработан за %v", duration) + }) +} + +// getEnv получает переменную окружения или возвращает значение по умолчанию +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// GetModelPath формирует путь к модели на основе выбранного голоса +func GetModelPath(modelDir, voice string) string { + // Если указан полный путь через MODEL_PATH, используем его + if modelPath := os.Getenv("MODEL_PATH"); modelPath != "" { + return modelPath + } + + // Формируем путь к модели на основе голоса + modelFile := fmt.Sprintf("ru_RU-%s-medium.onnx", voice) + return filepath.Join(modelDir, modelFile) +} + +// handleHelp рендерит README.md в HTML +func handleHelp(w http.ResponseWriter, r *http.Request) { + logger.Debug("GET /go-speech/help - отображение документации") + + // Простой рендеринг markdown в HTML + html := markdownToHTML(readmeContent) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(html)) +} + +// markdownToHTML конвертирует markdown в простой HTML +func markdownToHTML(md string) string { + html := ` + +
+ + +" + template.HTMLEscapeString(codeBlock.String()) + "\n"
+ codeBlock.Reset()
+ inCodeBlock = false
+ } else {
+ // Открываем блок кода
+ inCodeBlock = true
+ }
+ if inList {
+ html += "\n"
+ inList = false
+ }
+ continue
+ }
+
+ if inCodeBlock {
+ codeBlock.WriteString(line + "\n")
+ continue
+ }
+
+ // Заголовки
+ if strings.HasPrefix(trimmedLine, "# ") {
+ if inList {
+ html += "\n"
+ inList = false
+ }
+ html += "" + content + "
\n" + } + + // Закрываем открытые теги + if inList { + html += "\n" + } + + html += ` +` + return html +} + +// processInlineCode обрабатывает inline код в markdown +func processInlineCode(text string) string { + // Простая обработка inline кода `code` + parts := strings.Split(text, "`") + result := strings.Builder{} + for i, part := range parts { + if i%2 == 0 { + result.WriteString(part) + } else { + result.WriteString("")
+ result.WriteString(part)
+ result.WriteString("")
+ }
+ }
+ return result.String()
+}
diff --git a/main.go b/main.go
index e51b406..8a51b2f 100644
--- a/main.go
+++ b/main.go
@@ -5,7 +5,6 @@ import (
"crypto/tls"
"embed"
"fmt"
- "html/template"
"io"
"io/fs"
"log"
@@ -29,11 +28,42 @@ var staticFiles embed.FS
//go:embed README.md
var readmeContent string
+//go:embed VERSION.txt
+var versionContent string
+
+// getVersion возвращает версию приложения
+func getVersion() string {
+ if versionContent != "" {
+ return strings.TrimSpace(versionContent)
+ }
+ return "unknown"
+}
+
func main() {
+ // Обработка флага -V для вывода версии
+ if len(os.Args) > 1 && (os.Args[1] == "-V" || os.Args[1] == "--version") {
+ fmt.Printf("Go Speech TTS версия: %s\n", getVersion())
+ os.Exit(0)
+ }
+
// Получение конфигурации из переменных окружения
- port := getEnv("GO_SPEECH_PORT", "8443")
+
+ // Определение режима работы: HTTPS или HTTP
+ // GO_SPEECH_TLS=true включает HTTPS, по умолчанию (если не задано) - HTTP
+ useTLS := getEnv("GO_SPEECH_TLS", "false") == "true"
+
+ port := "8443"
+ if useTLS {
+ port = getEnv("GO_SPEECH_PORT", "8443")
+ } else {
+ port = getEnv("GO_SPEECH_PORT", "8080")
+ }
+
certFile := getEnv("GO_SPEECH_CERT_FILE", "certs/server.crt")
keyFile := getEnv("GO_SPEECH_KEY_FILE", "certs/server.key")
+ caCertFile := getEnv("GO_SPEECH_CA_CERT", "") // Путь к CA сертификату (опционально)
+ tlsDomains := getEnv("GO_SPEECH_TLS_DOMAINS", "") // Домены для сертификата через запятую
+
piperPath := getEnv("GO_SPEECH_PIPER_PATH", "/usr/local/bin/piper")
ffmpegPath := getEnv("GO_SPEECH_FFMPEG_PATH", "/usr/bin/ffmpeg")
@@ -68,45 +98,71 @@ func main() {
logger.Info("=== Запуск Go Speech TTS сервера ===")
logger.Debug("Режим отладки: ВКЛЮЧЕН")
logger.Debug("Конфигурация:")
- logger.Debug(" PORT: %s", port)
- logger.Debug(" CERT_FILE: %s", certFile)
- logger.Debug(" KEY_FILE: %s", keyFile)
- logger.Debug(" PIPER_PATH: %s", piperPath)
- logger.Debug(" FFMPEG_PATH: %s", ffmpegPath)
- logger.Debug(" MODEL_DIR: %s", modelDir)
- logger.Debug(" CACHE_DIR: %s", cacheDir)
+ logger.Debug(" GO_SPEECH_PORT: %s", port)
+ logger.Debug(" GO_SPEECH_TLS: %v (HTTPS: %v)", getEnv("GO_SPEECH_TLS", "не задано"), useTLS)
+ logger.Debug(" GO_SPEECH_CERT_FILE: %s", certFile)
+ logger.Debug(" GO_SPEECH_KEY_FILE: %s", keyFile)
+ if caCertFile != "" {
+ logger.Debug(" GO_SPEECH_CA_CERT: %s", caCertFile)
+ }
+ if tlsDomains != "" {
+ logger.Debug(" GO_SPEECH_TLS_DOMAINS: %s", tlsDomains)
+ }
+ logger.Debug(" GO_SPEECH_PIPER_PATH: %s", piperPath)
+ logger.Debug(" GO_SPEECH_FFMPEG_PATH: %s", ffmpegPath)
+ logger.Debug(" GO_SPEECH_MODEL_DIR: %s", modelDir)
+ logger.Debug(" GO_SPEECH_CACHE_DIR: %s", cacheDir)
logger.Debug(" GO_SPEECH_VOICE: %s", getEnv("GO_SPEECH_VOICE", "ruslan"))
logger.Debug(" GO_SPEECH_MODE: %s", getEnv("GO_SPEECH_MODE", "debug"))
logger.Info("Директория с моделями: %s", modelDir)
logger.Info("Директория кэша: %s", cacheDir)
- // Проверка наличия сертификатов
- logger.Debug("Проверка SSL сертификатов...")
- if _, err := os.Stat(certFile); os.IsNotExist(err) {
- logger.Error("SSL сертификат не найден: %s", certFile)
- log.Fatalf("SSL сертификат не найден: %s. Создайте сертификаты или укажите путь через CERT_FILE", certFile)
- }
- logger.Debug("SSL сертификат найден: %s", certFile)
+ var tlsConfig *tls.Config
- if _, err := os.Stat(keyFile); os.IsNotExist(err) {
- logger.Error("SSL ключ не найден: %s", keyFile)
- log.Fatalf("SSL ключ не найден: %s. Создайте ключ или укажите путь через KEY_FILE", keyFile)
- }
- logger.Debug("SSL ключ найден: %s", keyFile)
+ // Обработка TLS конфигурации только если включен HTTPS
+ if useTLS {
+ logger.Debug("Режим HTTPS включен, проверка сертификатов...")
- // Загрузка TLS сертификатов
- logger.Debug("Загрузка TLS сертификатов...")
- cert, err := tls.LoadX509KeyPair(certFile, keyFile)
- if err != nil {
- logger.Error("Ошибка загрузки TLS сертификатов: %v", err)
- log.Fatalf("Ошибка загрузки TLS сертификатов: %v", err)
- }
- logger.Debug("TLS сертификаты успешно загружены")
+ // Проверка наличия сертификатов
+ certExists := false
+ keyExists := false
- // Настройка TLS конфигурации
- tlsConfig := &tls.Config{
- Certificates: []tls.Certificate{cert},
- MinVersion: tls.VersionTLS12,
+ if _, err := os.Stat(certFile); err == nil {
+ certExists = true
+ logger.Debug("SSL сертификат найден: %s", certFile)
+ }
+
+ if _, err := os.Stat(keyFile); err == nil {
+ keyExists = true
+ logger.Debug("SSL ключ найден: %s", keyFile)
+ }
+
+ // Если сертификаты отсутствуют - генерируем их
+ if !certExists || !keyExists {
+ logger.Info("Сертификаты не найдены, начинаем генерацию...")
+ if err := generateCertificate(certFile, keyFile, caCertFile, tlsDomains); err != nil {
+ logger.Error("Ошибка генерации сертификатов: %v", err)
+ log.Fatalf("Ошибка генерации сертификатов: %v", err)
+ }
+ logger.Info("Сертификаты успешно сгенерированы: %s, %s", certFile, keyFile)
+ }
+
+ // Загрузка TLS сертификатов
+ logger.Debug("Загрузка TLS сертификатов...")
+ cert, err := tls.LoadX509KeyPair(certFile, keyFile)
+ if err != nil {
+ logger.Error("Ошибка загрузки TLS сертификатов: %v", err)
+ log.Fatalf("Ошибка загрузки TLS сертификатов: %v", err)
+ }
+ logger.Debug("TLS сертификаты успешно загружены")
+
+ // Настройка TLS конфигурации
+ tlsConfig = &tls.Config{
+ Certificates: []tls.Certificate{cert},
+ MinVersion: tls.VersionTLS12,
+ }
+ } else {
+ logger.Info("Режим HTTP (TLS отключен)")
}
// Статические файлы для фронтенда
@@ -137,6 +193,13 @@ func main() {
w.Write([]byte("OK"))
})
logger.Debug(" GET /go-speech/api/v1/health - проверка работоспособности")
+ mux.HandleFunc("/go-speech/api/v1/version", func(w http.ResponseWriter, r *http.Request) {
+ logger.Debug("GET /go-speech/api/v1/version - получение версии")
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprintf(w, `{"version":"%s"}`, getVersion())
+ })
+ logger.Debug(" GET /go-speech/api/v1/version - получение версии")
// Веб-интерфейс
mux.HandleFunc("/go-speech/front", func(w http.ResponseWriter, r *http.Request) {
@@ -152,8 +215,20 @@ func main() {
return
}
defer indexFile.Close()
+
+ // Читаем содержимое index.html
+ indexContent, err := io.ReadAll(indexFile)
+ if err != nil {
+ logger.Error("Ошибка чтения index.html: %v", err)
+ http.Error(w, "Ошибка чтения фронтенда", http.StatusInternalServerError)
+ return
+ }
+
+ // Заменяем плейсхолдер версии
+ indexContentStr := strings.ReplaceAll(string(indexContent), "{{VERSION}}", getVersion())
+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
- io.Copy(w, indexFile)
+ w.Write([]byte(indexContentStr))
})
logger.Debug(" GET /go-speech/front - веб-интерфейс")
@@ -161,6 +236,15 @@ func main() {
mux.Handle("/go-speech/front/", http.StripPrefix("/go-speech/front/", http.FileServer(http.FS(staticFS))))
logger.Debug(" GET /go-speech/front/* - статические ресурсы")
+ // Обработчик для шрифтов
+ fontsFS, err := fs.Sub(staticFiles, "static/fonts")
+ if err == nil {
+ mux.Handle("/go-speech/fonts/", http.StripPrefix("/go-speech/fonts/", http.FileServer(http.FS(fontsFS))))
+ logger.Debug(" GET /go-speech/fonts/* - шрифты")
+ } else {
+ logger.Warn("Не удалось загрузить шрифты: %v", err)
+ }
+
// Помощь - рендеринг README.md
mux.HandleFunc("/go-speech/help", handleHelp)
logger.Debug(" GET /go-speech/help - документация")
@@ -168,16 +252,25 @@ func main() {
server := &http.Server{
Addr: "0.0.0.0:" + port,
Handler: loggingMiddleware(mux),
- TLSConfig: tlsConfig,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
+ // Добавляем TLS конфигурацию только если включен HTTPS
+ if useTLS {
+ server.TLSConfig = tlsConfig
+ }
+
logger.Debug("Настройки HTTP сервера:")
logger.Debug(" ReadTimeout: %v", server.ReadTimeout)
logger.Debug(" WriteTimeout: %v", server.WriteTimeout)
logger.Debug(" IdleTimeout: %v", server.IdleTimeout)
+ if useTLS {
+ logger.Debug(" TLS: включен")
+ } else {
+ logger.Debug(" TLS: отключен")
+ }
// Graceful shutdown
go func() {
@@ -190,17 +283,22 @@ func main() {
}
logger.Debug("IPv4 listener успешно создан: %v", listener.Addr())
- // Создаем TLS listener
- logger.Debug("Создание TLS listener...")
- tlsListener := tls.NewListener(listener, tlsConfig)
- logger.Debug("TLS listener успешно создан")
+ protocol := "http"
+ if useTLS {
+ protocol = "https"
+ // Создаем TLS listener
+ logger.Debug("Создание TLS listener...")
+ tlsListener := tls.NewListener(listener, tlsConfig)
+ logger.Debug("TLS listener успешно создан")
+ listener = tlsListener
+ }
- logger.Info("Сервер запущен на https://0.0.0.0:%s (IPv4)", port)
- logger.Info("Простой веб-интерфейс доступен на https://localhost:%s/go-speech/front", port)
- logger.Info("Справка доступна по пути https://localhost:%s/go-speech/help", port)
+ logger.Info("Сервер запущен на %s://0.0.0.0:%s (IPv4)", protocol, port)
+ logger.Info("Простой веб-интерфейс доступен на %s://localhost:%s/go-speech/front", protocol, port)
+ logger.Info("Справка доступна по пути %s://localhost:%s/go-speech/help", protocol, port)
logger.Debug("Запуск server.Serve()...")
- if err := server.Serve(tlsListener); err != nil && err != http.ErrServerClosed {
+ if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
logger.Error("Ошибка запуска сервера: %v", err)
log.Fatalf("Ошибка запуска сервера: %v", err)
}
@@ -226,203 +324,3 @@ func main() {
logger.Info("Сервер остановлен")
}
-
-// loggingMiddleware добавляет логирование запросов
-func loggingMiddleware(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- start := time.Now()
- logger.Debug("Входящий запрос: %s %s от %s", r.Method, r.URL.Path, r.RemoteAddr)
- logger.Debug(" User-Agent: %s", r.UserAgent())
- logger.Debug(" Content-Type: %s", r.Header.Get("Content-Type"))
- logger.Debug(" Content-Length: %s", r.Header.Get("Content-Length"))
-
- next.ServeHTTP(w, r)
-
- duration := time.Since(start)
- logger.Info("%s %s %v", r.Method, r.URL.Path, duration)
- logger.Debug("Запрос обработан за %v", duration)
- })
-}
-
-// getEnv получает переменную окружения или возвращает значение по умолчанию
-func getEnv(key, defaultValue string) string {
- if value := os.Getenv(key); value != "" {
- return value
- }
- return defaultValue
-}
-
-// GetModelPath формирует путь к модели на основе выбранного голоса
-func GetModelPath(modelDir, voice string) string {
- // Если указан полный путь через MODEL_PATH, используем его
- if modelPath := os.Getenv("MODEL_PATH"); modelPath != "" {
- return modelPath
- }
-
- // Формируем путь к модели на основе голоса
- modelFile := fmt.Sprintf("ru_RU-%s-medium.onnx", voice)
- return filepath.Join(modelDir, modelFile)
-}
-
-// handleHelp рендерит README.md в HTML
-func handleHelp(w http.ResponseWriter, r *http.Request) {
- logger.Debug("GET /go-speech/help - отображение документации")
-
- // Простой рендеринг markdown в HTML
- html := markdownToHTML(readmeContent)
-
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.WriteHeader(http.StatusOK)
- w.Write([]byte(html))
-}
-
-// markdownToHTML конвертирует markdown в простой HTML
-func markdownToHTML(md string) string {
- html := `
-
-
-
-
- " + template.HTMLEscapeString(codeBlock.String()) + "\n"
- codeBlock.Reset()
- inCodeBlock = false
- } else {
- // Открываем блок кода
- inCodeBlock = true
- }
- if inList {
- html += "\n"
- inList = false
- }
- continue
- }
-
- if inCodeBlock {
- codeBlock.WriteString(line + "\n")
- continue
- }
-
- // Заголовки
- if strings.HasPrefix(trimmedLine, "# ") {
- if inList {
- html += "\n"
- inList = false
- }
- html += "" + content + "
\n" - } - - // Закрываем открытые теги - if inList { - html += "\n" - } - - html += ` -` - return html -} - -// processInlineCode обрабатывает inline код в markdown -func processInlineCode(text string) string { - // Простая обработка inline кода `code` - parts := strings.Split(text, "`") - result := strings.Builder{} - for i, part := range parts { - if i%2 == 0 { - result.WriteString(part) - } else { - result.WriteString("")
- result.WriteString(part)
- result.WriteString("")
- }
- }
- return result.String()
-}
diff --git a/podman-run.sh b/podman-run.sh
deleted file mode 100644
index 7e3e986..0000000
--- a/podman-run.sh
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/bash
-
-podman run -d --name go-speech --restart=always -p 7443:7443 \
--v "$(pwd)/certs:/app/certs:ro" \
--e GO_SPEECH_VOICE=ruslan \
--e GO_SPEECH_PORT=7443 \
-kuznetcovay/go-speech:v1.0.4
diff --git a/shell/build-n-export.sh b/shell/build-n-export.sh
new file mode 100644
index 0000000..0e45be5
--- /dev/null
+++ b/shell/build-n-export.sh
@@ -0,0 +1,86 @@
+#!/bin/bash
+
+# Скрипт для сборки Podman образа и опционального экспорта в tar.gz
+# Использование: ./build-n-export.sh