Compare commits
12 Commits
cf3ec88bb1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f42570fce2 | ||
|
|
6ec0d75f0f | ||
|
|
7cf5bca8d2 | ||
|
|
118bbb9261 | ||
|
|
1409bb265c | ||
|
|
1a54862e91 | ||
|
|
a21bb73175 | ||
|
|
cfc9e23697 | ||
|
|
a6d2f82633 | ||
|
|
a638dd9441 | ||
|
|
8a431ff02f | ||
|
|
f63522f86e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
*.yaml
|
*.yaml
|
||||||
bin/http_logger
|
bin/http_logger
|
||||||
|
/http_logger
|
||||||
*.out
|
*.out
|
||||||
18
README.md
18
README.md
@@ -1,2 +1,20 @@
|
|||||||
# http_logger
|
# http_logger
|
||||||
|
|
||||||
|
HTTP-логгер: принимает входящие HTTP-запросы и пересылает их в настроенный
|
||||||
|
канал уведомлений. Канал выбирается первым сегментом пути:
|
||||||
|
|
||||||
|
- `POST /telegram` → Telegram
|
||||||
|
- `POST /pachca` → Pachca (Пачка, входящий вебхук)
|
||||||
|
- `POST /email` → Email
|
||||||
|
|
||||||
|
Каналы настраиваются в `config.yaml` (пример — `config.yaml.default`).
|
||||||
|
Несконфигурированный путь возвращает `404`, ошибка отправки — `502`.
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
./build.sh
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
cp config.yaml.default config.yaml # отредактировать токены/адреса
|
||||||
|
./bin/http_logger
|
||||||
|
|||||||
48
config.go
Normal file
48
config.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config — структура для чтения YAML-файла
|
||||||
|
type Config struct {
|
||||||
|
ListenAddresses []string `yaml:"listen_addresses"`
|
||||||
|
LogDir string `yaml:"log_dir"`
|
||||||
|
Telegram *ConfigTelegram `yaml:"telegram"`
|
||||||
|
Pachca *ConfigPachca `yaml:"pachca"`
|
||||||
|
Email *ConfigEmail `yaml:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigTelegram struct {
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
GroupID int64 `yaml:"group_id"`
|
||||||
|
DisableIPV6 *bool `yaml:"disable_ipv6"` // nil → true
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigPachca struct {
|
||||||
|
WebhookURL string `yaml:"webhook_url"` // входящий вебхук Pachca
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigEmail struct {
|
||||||
|
SMTPAddr string `yaml:"smtp_addr"`
|
||||||
|
Username string `yaml:"username"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
From string `yaml:"from"`
|
||||||
|
To []string `yaml:"to"`
|
||||||
|
Subject string `yaml:"subject"` // "" → "HTTP Logger"
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfig читает YAML-конфиг из файла
|
||||||
|
func loadConfig(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cfg Config
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
@@ -4,6 +4,21 @@ listen_addresses:
|
|||||||
|
|
||||||
log_dir: "logs"
|
log_dir: "logs"
|
||||||
|
|
||||||
|
# Любую секцию можно опустить — тогда соответствующий путь вернёт 404.
|
||||||
|
|
||||||
telegram:
|
telegram:
|
||||||
token:"*****"
|
token: "*****"
|
||||||
group_id: -10012345
|
group_id: -10012345
|
||||||
|
disable_ipv6: true # опционально, по умолчанию true
|
||||||
|
|
||||||
|
pachca:
|
||||||
|
webhook_url: "https://api.pachca.com/webhooks/XXXX" # входящий вебхук
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp_addr: "smtp.example.com:587"
|
||||||
|
username: "user@example.com"
|
||||||
|
password: "*****"
|
||||||
|
from: "logger@example.com"
|
||||||
|
to:
|
||||||
|
- "alert@example.com"
|
||||||
|
subject: "HTTP Logger" # опционально
|
||||||
|
|||||||
849
docs/superpowers/plans/2026-06-14-notification-channels.md
Normal file
849
docs/superpowers/plans/2026-06-14-notification-channels.md
Normal file
@@ -0,0 +1,849 @@
|
|||||||
|
# Настраиваемые каналы уведомлений — план реализации
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Сделать каналы уведомлений настраиваемыми через конфиг и добавить Pachca и Email к существующему Telegram, выбирая канал по пути URL (`/telegram`, `/pachca`, `/email`).
|
||||||
|
|
||||||
|
**Architecture:** Общая функция `ExtractRequest` читает входящий HTTP-запрос один раз в нейтральную `RequestData`. Интерфейс `Notifier` с тремя реализациями (telegram/pachca/email) форматирует данные под свой канал и отправляет через библиотеку `gitea.mediatoday.ru/mt/notify`. На старте из конфига собирается `map[string]Notifier`; хендлер маршрутизирует по первому сегменту пути.
|
||||||
|
|
||||||
|
**Tech Stack:** Go 1.25+, `gitea.mediatoday.ru/mt/notify`, `gopkg.in/yaml.v3`, стандартные `net/http`, `net/mail`, `net/smtp`, `html`, `net/http/httptest`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура файлов
|
||||||
|
|
||||||
|
- `go.mod` / `go.sum` — заменить зависимость `git.gm6.ru/icewind/notify` на `gitea.mediatoday.ru/mt/notify`.
|
||||||
|
- `config.go` — **создать**: типы конфига и `loadConfig` (вынести из `main.go`).
|
||||||
|
- `request.go` — **создать**: `RequestData`, `GetRemoteAddr`, `ExtractRequest`.
|
||||||
|
- `notifier.go` — **создать**: интерфейс `Notifier` и три реализации + `BuildNotifiers`.
|
||||||
|
- `main.go` — **изменить**: убрать вынесенное, собрать каналы, маршрутизировать по map.
|
||||||
|
- `request_test.go` — **создать**: тесты `ExtractRequest` / `GetRemoteAddr`.
|
||||||
|
- `notifier_test.go` — **создать**: тесты форматирования и маршрутизации хендлера через мок.
|
||||||
|
- `config.yaml.default` — **изменить**: добавить секции pachca/email, исправить опечатку.
|
||||||
|
- `handler.go` — **создать**: фабрика хендлера, принимающая `map[string]Notifier` (для тестируемости).
|
||||||
|
|
||||||
|
Заметка по именованию: текущий тип называется `ConfigTelegraam` (опечатка) — переименовываем в `ConfigTelegram`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Обновить зависимость notify на новый путь
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `go.mod`
|
||||||
|
- Modify: `go.sum`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Заменить импорт во всех .go файлах**
|
||||||
|
|
||||||
|
В `main.go` строка импорта уже `git.gm6.ru/icewind/notify`. Заменить на новый путь:
|
||||||
|
|
||||||
|
```go
|
||||||
|
"gitea.mediatoday.ru/mt/notify"
|
||||||
|
```
|
||||||
|
|
||||||
|
(Имя пакета остаётся `notify`, поэтому остальной код не меняется на этом шаге.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Обновить модуль и зачистить старую зависимость**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /home/soer/projects/http_logger
|
||||||
|
go get gitea.mediatoday.ru/mt/notify@v0.0.0-20260405185738-6f5db00fcb34
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
Expected: в `go.mod` появляется `gitea.mediatoday.ru/mt/notify v0.0.0-20260405185738-6f5db00fcb34` (без `// indirect`), строка `git.gm6.ru/icewind/notify` исчезает.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Проверить сборку**
|
||||||
|
|
||||||
|
Run: `go build ./...`
|
||||||
|
Expected: успешная сборка без ошибок (код ещё прежний, только путь импорта новый).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add go.mod go.sum main.go
|
||||||
|
git commit -m "build: перейти на gitea.mediatoday.ru/mt/notify"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Вынести конфиг в config.go с новыми секциями
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `config.go`
|
||||||
|
- Modify: `main.go` (удалить перенесённое)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Создать config.go**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config — структура для чтения YAML-файла
|
||||||
|
type Config struct {
|
||||||
|
ListenAddresses []string `yaml:"listen_addresses"`
|
||||||
|
LogDir string `yaml:"log_dir"`
|
||||||
|
Telegram *ConfigTelegram `yaml:"telegram"`
|
||||||
|
Pachca *ConfigPachca `yaml:"pachca"`
|
||||||
|
Email *ConfigEmail `yaml:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigTelegram struct {
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
GroupID int64 `yaml:"group_id"`
|
||||||
|
DisableIPV6 *bool `yaml:"disable_ipv6"` // nil → true
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigPachca struct {
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
ChatID int64 `yaml:"chat_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigEmail struct {
|
||||||
|
SMTPAddr string `yaml:"smtp_addr"`
|
||||||
|
Username string `yaml:"username"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
From string `yaml:"from"`
|
||||||
|
To []string `yaml:"to"`
|
||||||
|
Subject string `yaml:"subject"` // "" → "HTTP Logger"
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfig читает YAML-конфиг из файла
|
||||||
|
func loadConfig(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cfg Config
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Удалить перенесённое из main.go**
|
||||||
|
|
||||||
|
В `main.go` удалить: типы `Config`, `ConfigTelegraam`, функцию `loadConfig`, и импорты `os` и `gopkg.in/yaml.v3` (теперь не нужны в main.go). Оставить остальной код пока как есть — он будет переписан в Task 5. Импорт `yaml` и `os` останется неиспользуемым в main.go, поэтому временно сборка может ругаться — это нормально, исправится после удаления использования. Чтобы не ломать сборку между задачами, удаление импортов делается вместе с удалением типов в этом же шаге.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Проверить сборку**
|
||||||
|
|
||||||
|
Run: `go build ./...`
|
||||||
|
Expected: успешная сборка (main.go всё ещё использует только `cfg.Telegram`, типы теперь из config.go).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add config.go main.go
|
||||||
|
git commit -m "refactor: вынести конфиг в config.go, добавить секции pachca/email"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Извлечение данных запроса (request.go) — TDD
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `request.go`
|
||||||
|
- Create: `request_test.go`
|
||||||
|
- Modify: `main.go` (удалить `GetRemoteAddr` и часть `BuildMessage`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Написать падающий тест**
|
||||||
|
|
||||||
|
Создать `request_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetRemoteAddr(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
set func(r *http.Request)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"xff", func(r *http.Request) { r.Header.Set("X-Forwarded-For", "1.1.1.1, 2.2.2.2") }, "1.1.1.1"},
|
||||||
|
{"realip", func(r *http.Request) { r.Header.Set("X-Real-IP", "3.3.3.3") }, "3.3.3.3"},
|
||||||
|
{"remoteaddr", func(r *http.Request) { r.RemoteAddr = "4.4.4.4:5678" }, "4.4.4.4"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("GET", "/telegram", nil)
|
||||||
|
r.RemoteAddr = ""
|
||||||
|
tt.set(r)
|
||||||
|
if got := GetRemoteAddr(r); got != tt.want {
|
||||||
|
t.Fatalf("got %q want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRequest(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("POST", "http://example.com/telegram?x=1", strings.NewReader("hello"))
|
||||||
|
r.Header.Set("X-Real-IP", "9.9.9.9")
|
||||||
|
data := ExtractRequest(r)
|
||||||
|
if data.Method != "POST" {
|
||||||
|
t.Errorf("method = %q", data.Method)
|
||||||
|
}
|
||||||
|
if data.RemoteAddr != "9.9.9.9" {
|
||||||
|
t.Errorf("remoteaddr = %q", data.RemoteAddr)
|
||||||
|
}
|
||||||
|
if data.Body != "hello" {
|
||||||
|
t.Errorf("body = %q", data.Body)
|
||||||
|
}
|
||||||
|
if data.URL != "/telegram?x=1" {
|
||||||
|
t.Errorf("url = %q", data.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRequestBodyLimit(t *testing.T) {
|
||||||
|
big := strings.Repeat("a", 2000)
|
||||||
|
r := httptest.NewRequest("POST", "/telegram", strings.NewReader(big))
|
||||||
|
r.ContentLength = int64(len(big))
|
||||||
|
data := ExtractRequest(r)
|
||||||
|
if data.Body != "too long" {
|
||||||
|
t.Errorf("expected 'too long', got len %d", len(data.Body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Запустить тест — убедиться, что падает**
|
||||||
|
|
||||||
|
Run: `go test ./... -run TestExtractRequest -v`
|
||||||
|
Expected: FAIL / build error — `GetRemoteAddr` и `ExtractRequest` ещё не существуют (после Task 5 они удалятся из main.go; на этом шаге их пока нет в request.go).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Создать request.go**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestData — извлечённые данные входящего запроса
|
||||||
|
type RequestData struct {
|
||||||
|
Time time.Time
|
||||||
|
Method string
|
||||||
|
Host string
|
||||||
|
URL string
|
||||||
|
RemoteAddr string
|
||||||
|
Header http.Header
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRemoteAddr(r *http.Request) string {
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
ips := splitFirst(xff)
|
||||||
|
if ips != "" {
|
||||||
|
return ips
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
|
||||||
|
return realIP
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitFirst(xff string) string {
|
||||||
|
for i := 0; i < len(xff); i++ {
|
||||||
|
if xff[i] == ',' {
|
||||||
|
return trimSpace(xff[:i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimSpace(xff)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimSpace(s string) string {
|
||||||
|
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') {
|
||||||
|
s = s[1:]
|
||||||
|
}
|
||||||
|
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
|
||||||
|
s = s[:len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractRequest читает запрос один раз и возвращает нейтральные данные.
|
||||||
|
func ExtractRequest(r *http.Request) RequestData {
|
||||||
|
var body []byte
|
||||||
|
if r.ContentLength > 1024 {
|
||||||
|
body = []byte("too long")
|
||||||
|
} else {
|
||||||
|
body, _ = io.ReadAll(r.Body)
|
||||||
|
_ = r.Body.Close()
|
||||||
|
}
|
||||||
|
return RequestData{
|
||||||
|
Time: time.Now(),
|
||||||
|
Method: r.Method,
|
||||||
|
Host: r.Host,
|
||||||
|
URL: r.URL.String(),
|
||||||
|
RemoteAddr: GetRemoteAddr(r),
|
||||||
|
Header: r.Header,
|
||||||
|
Body: string(body),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Заметка: `splitFirst`/`trimSpace` дублируют `strings`-логику без лишнего импорта; допустимо просто использовать `strings.Split`/`strings.TrimSpace` — если предпочитаешь `strings`, замени тело `GetRemoteAddr` на исходный вариант из старого `main.go` и добавь `"strings"` в импорт. Главное — поведение из теста.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Запустить тесты — убедиться, что проходят**
|
||||||
|
|
||||||
|
Run: `go test ./... -run TestExtractRequest -v && go test ./... -run TestGetRemoteAddr -v`
|
||||||
|
Expected: PASS. (Если `GetRemoteAddr` всё ещё объявлен в main.go — будет ошибка дублирования; тогда сначала выполни Step 5.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Удалить дубликаты из main.go**
|
||||||
|
|
||||||
|
В `main.go` удалить функции `GetRemoteAddr` и `BuildMessage` целиком и неиспользуемые импорты (`io`, `net`, `time`, `fmt` — те, что больше не нужны). Логика `BuildMessage` (форматирование) переедет в notifier.go (Task 4). На этом шаге сборка main.go временно сломается, т.к. `handler` всё ещё вызывает `BuildMessage` — это будет починено в Task 5. Чтобы не коммитить несобираемое состояние, **объедини Step 5 с Task 5** при выполнении (см. примечание ниже).
|
||||||
|
|
||||||
|
> Примечание для исполнителя: Task 3 и Task 5 затрагивают `main.go` совместно. Рекомендуется выполнять их подряд и коммитить после Task 5, либо закоммитить request.go + тесты отдельно (Step 1–4), а правку main.go сделать в Task 5.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit (только request.go и тесты)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add request.go request_test.go
|
||||||
|
git commit -m "feat: ExtractRequest — извлечение данных запроса с тестами"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Интерфейс Notifier и реализации каналов — TDD
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `notifier.go`
|
||||||
|
- Create: `notifier_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Написать падающий тест форматирования**
|
||||||
|
|
||||||
|
Создать `notifier_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sampleData() RequestData {
|
||||||
|
return RequestData{
|
||||||
|
Time: time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC),
|
||||||
|
Method: "POST",
|
||||||
|
Host: "example.com",
|
||||||
|
URL: "/telegram",
|
||||||
|
RemoteAddr: "1.2.3.4",
|
||||||
|
Header: http.Header{"X-Test": []string{"v"}},
|
||||||
|
Body: "payload<>",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatMarkdown(t *testing.T) {
|
||||||
|
out := formatMarkdown(sampleData())
|
||||||
|
if !strings.HasPrefix(out, "```") || !strings.HasSuffix(out, "```") {
|
||||||
|
t.Fatalf("expected ``` wrapping, got: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "POST") || !strings.Contains(out, "1.2.3.4") {
|
||||||
|
t.Errorf("missing fields: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "X-Test: v") {
|
||||||
|
t.Errorf("missing header: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatHTML(t *testing.T) {
|
||||||
|
out := formatHTML(sampleData())
|
||||||
|
if !strings.Contains(out, "<pre>") || !strings.Contains(out, "</pre>") {
|
||||||
|
t.Fatalf("expected <pre> wrapping, got: %q", out)
|
||||||
|
}
|
||||||
|
// тело экранируется
|
||||||
|
if !strings.Contains(out, "payload<>") {
|
||||||
|
t.Errorf("body not escaped: %q", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "payload<>") {
|
||||||
|
t.Errorf("unescaped body present: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Запустить — убедиться, что падает**
|
||||||
|
|
||||||
|
Run: `go test ./... -run TestFormat -v`
|
||||||
|
Expected: FAIL / build error — `formatMarkdown` и `formatHTML` не существуют.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Создать notifier.go**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"net/mail"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.mediatoday.ru/mt/notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notifier — канал отправки уведомлений
|
||||||
|
type Notifier interface {
|
||||||
|
Send(data RequestData) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatPlain собирает текстовое тело без обёртки.
|
||||||
|
func formatPlain(d RequestData) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "[%s] %s %s%s\n", d.Time.Format("2006-01-02T15:04:05Z07:00"), d.Method, d.Host, d.URL)
|
||||||
|
fmt.Fprintf(&b, "RemoteAddr: %s\n", d.RemoteAddr)
|
||||||
|
b.WriteString("Headers:\n")
|
||||||
|
for name, values := range d.Header {
|
||||||
|
for _, v := range values {
|
||||||
|
fmt.Fprintf(&b, " %s: %s\n", name, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "Body:\n%s\n", d.Body)
|
||||||
|
b.WriteString("----\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatMarkdown — обёртка в код-блок для Telegram/Pachca.
|
||||||
|
func formatMarkdown(d RequestData) string {
|
||||||
|
return "```" + formatPlain(d) + "```"
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatHTML — обёртка в <pre> с экранированием для Email.
|
||||||
|
func formatHTML(d RequestData) string {
|
||||||
|
return "<pre>" + html.EscapeString(formatPlain(d)) + "</pre>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Telegram ---
|
||||||
|
|
||||||
|
type telegramNotifier struct {
|
||||||
|
bot *notify.Bot
|
||||||
|
chatID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *telegramNotifier) Send(d RequestData) error {
|
||||||
|
_, err := t.bot.SendTextMessage(t.chatID, formatMarkdown(d))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pachca ---
|
||||||
|
|
||||||
|
type pachcaNotifier struct {
|
||||||
|
client *notify.Pachca
|
||||||
|
chatID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pachcaNotifier) Send(d RequestData) error {
|
||||||
|
_, err := p.client.SendMessage(p.chatID, formatMarkdown(d))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Email ---
|
||||||
|
|
||||||
|
type emailNotifier struct {
|
||||||
|
auth notify.SmtpAuth
|
||||||
|
from mail.Address
|
||||||
|
to []mail.Address
|
||||||
|
subject string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *emailNotifier) Send(d RequestData) error {
|
||||||
|
return notify.SendEmailHTML(e.auth, formatHTML(d), e.subject, e.from, e.to...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildNotifiers собирает map каналов из конфига. Добавляет только заданные секции.
|
||||||
|
func BuildNotifiers(cfg *Config) (map[string]Notifier, error) {
|
||||||
|
notifiers := map[string]Notifier{}
|
||||||
|
|
||||||
|
if cfg.Telegram != nil {
|
||||||
|
disableIPV6 := true
|
||||||
|
if cfg.Telegram.DisableIPV6 != nil {
|
||||||
|
disableIPV6 = *cfg.Telegram.DisableIPV6
|
||||||
|
}
|
||||||
|
bot, err := notify.NewTelegram(cfg.Telegram.Token, disableIPV6)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("telegram: %w", err)
|
||||||
|
}
|
||||||
|
notifiers["telegram"] = &telegramNotifier{bot: bot, chatID: cfg.Telegram.GroupID}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Pachca != nil {
|
||||||
|
notifiers["pachca"] = &pachcaNotifier{
|
||||||
|
client: notify.NewPachca(cfg.Pachca.Token),
|
||||||
|
chatID: cfg.Pachca.ChatID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Email != nil {
|
||||||
|
host, _, err := splitHostPortLenient(cfg.Email.SMTPAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("email smtp_addr: %w", err)
|
||||||
|
}
|
||||||
|
from, err := mail.ParseAddress(cfg.Email.From)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("email from: %w", err)
|
||||||
|
}
|
||||||
|
var to []mail.Address
|
||||||
|
for _, addr := range cfg.Email.To {
|
||||||
|
a, err := mail.ParseAddress(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("email to %q: %w", addr, err)
|
||||||
|
}
|
||||||
|
to = append(to, *a)
|
||||||
|
}
|
||||||
|
if len(to) == 0 {
|
||||||
|
return nil, fmt.Errorf("email: не задан ни один получатель (to)")
|
||||||
|
}
|
||||||
|
subject := cfg.Email.Subject
|
||||||
|
if subject == "" {
|
||||||
|
subject = "HTTP Logger"
|
||||||
|
}
|
||||||
|
notifiers["email"] = &emailNotifier{
|
||||||
|
auth: notify.SmtpAuth{
|
||||||
|
Addr: cfg.Email.SMTPAddr,
|
||||||
|
Auth: smtp.PlainAuth("", cfg.Email.Username, cfg.Email.Password, host),
|
||||||
|
},
|
||||||
|
from: *from,
|
||||||
|
to: to,
|
||||||
|
subject: subject,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return notifiers, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Добавить хелпер извлечения хоста (в notifier.go, под `BuildNotifiers`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "net" // добавить в блок импортов notifier.go
|
||||||
|
|
||||||
|
func splitHostPortLenient(addr string) (host, port string, err error) {
|
||||||
|
return net.SplitHostPort(addr)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Примечание: `splitHostPortLenient` — тонкая обёртка над `net.SplitHostPort`; можно вызвать `net.SplitHostPort` напрямую и убрать хелпер. Оставлено для явности. Не забудь добавить `"net"` в импорты notifier.go.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Запустить тесты — убедиться, что проходят**
|
||||||
|
|
||||||
|
Run: `go test ./... -run TestFormat -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add notifier.go notifier_test.go
|
||||||
|
git commit -m "feat: интерфейс Notifier + каналы telegram/pachca/email"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Хендлер с маршрутизацией по map (handler.go) — TDD
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `handler.go`
|
||||||
|
- Create/Modify: `notifier_test.go` (добавить тесты хендлера)
|
||||||
|
- Modify: `main.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Написать падающий тест хендлера**
|
||||||
|
|
||||||
|
Добавить в `notifier_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockNotifier struct {
|
||||||
|
called bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockNotifier) Send(d RequestData) error {
|
||||||
|
m.called = true
|
||||||
|
return m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerRouting(t *testing.T) {
|
||||||
|
tg := &mockNotifier{}
|
||||||
|
h := makeHandler(map[string]Notifier{"telegram": tg})
|
||||||
|
|
||||||
|
// известный канал → 200, Send вызван
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h(rec, httptest.NewRequest("POST", "/telegram", strings.NewReader("x")))
|
||||||
|
if rec.Code != 200 || !tg.called {
|
||||||
|
t.Fatalf("telegram: code=%d called=%v", rec.Code, tg.called)
|
||||||
|
}
|
||||||
|
|
||||||
|
// неизвестный канал → 404
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h(rec, httptest.NewRequest("POST", "/unknown", strings.NewReader("x")))
|
||||||
|
if rec.Code != 404 {
|
||||||
|
t.Fatalf("unknown: code=%d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// пустой путь → 404
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h(rec, httptest.NewRequest("POST", "/", nil))
|
||||||
|
if rec.Code != 404 {
|
||||||
|
t.Fatalf("empty: code=%d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerSendError(t *testing.T) {
|
||||||
|
failing := &mockNotifier{err: errSend}
|
||||||
|
h := makeHandler(map[string]Notifier{"telegram": failing})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h(rec, httptest.NewRequest("POST", "/telegram", strings.NewReader("x")))
|
||||||
|
if rec.Code != 502 {
|
||||||
|
t.Fatalf("expected 502, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Добавить в начало `notifier_test.go` объявление ошибки рядом с другими переменными:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var errSend = errors.New("send failed")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Запустить — убедиться, что падает**
|
||||||
|
|
||||||
|
Run: `go test ./... -run TestHandler -v`
|
||||||
|
Expected: FAIL / build error — `makeHandler` не существует.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Создать handler.go**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// makeHandler возвращает http-хендлер, маршрутизирующий по первому сегменту пути.
|
||||||
|
func makeHandler(notifiers map[string]Notifier) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths := strings.Split(r.URL.Path, "/")
|
||||||
|
if len(paths) < 2 || paths[1] == "" {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, ok := notifiers[paths[1]]
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := ExtractRequest(r)
|
||||||
|
if err := n.Send(data); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
_, _ = w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("OK"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Переписать main.go**
|
||||||
|
|
||||||
|
`main.go` целиком:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.Println("Запуск приложения")
|
||||||
|
|
||||||
|
cfg, err := loadConfig("config.yaml")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Ошибка загрузки конфига: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifiers, err := BuildNotifiers(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Ошибка инициализации каналов: %v", err)
|
||||||
|
}
|
||||||
|
if len(notifiers) == 0 {
|
||||||
|
log.Fatal("не сконфигурирован ни один канал уведомлений")
|
||||||
|
}
|
||||||
|
|
||||||
|
http.HandleFunc("/", makeHandler(notifiers))
|
||||||
|
|
||||||
|
for _, addr := range cfg.ListenAddresses {
|
||||||
|
addr := addr
|
||||||
|
go func() {
|
||||||
|
log.Printf("Слушаю %s ...", addr)
|
||||||
|
if err := http.ListenAndServe(addr, nil); err != nil {
|
||||||
|
log.Printf("Ошибка на %s: %v", addr, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
select {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Запустить все тесты**
|
||||||
|
|
||||||
|
Run: `go test ./...`
|
||||||
|
Expected: PASS по всем тестам (request, format, handler).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Проверить сборку бинаря**
|
||||||
|
|
||||||
|
Run: `go build ./... && ./build.sh`
|
||||||
|
Expected: успешная сборка, бинарь в `bin/http_logger`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add handler.go main.go notifier_test.go
|
||||||
|
git commit -m "feat: маршрутизация каналов по пути URL через map[string]Notifier"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Обновить config.yaml.default
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `config.yaml.default`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Переписать config.yaml.default**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
listen_addresses:
|
||||||
|
- ":8080"
|
||||||
|
- "127.0.0.1:9090"
|
||||||
|
|
||||||
|
log_dir: "logs"
|
||||||
|
|
||||||
|
# Любую секцию можно опустить — тогда соответствующий путь вернёт 404.
|
||||||
|
|
||||||
|
telegram:
|
||||||
|
token: "*****"
|
||||||
|
group_id: -10012345
|
||||||
|
disable_ipv6: true # опционально, по умолчанию true
|
||||||
|
|
||||||
|
pachca:
|
||||||
|
token: "*****"
|
||||||
|
chat_id: 12345
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp_addr: "smtp.example.com:587"
|
||||||
|
username: "user@example.com"
|
||||||
|
password: "*****"
|
||||||
|
from: "logger@example.com"
|
||||||
|
to:
|
||||||
|
- "alert@example.com"
|
||||||
|
subject: "HTTP Logger" # опционально
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Проверить, что пример валиден**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cp config.yaml.default /tmp/cfg_check.yaml
|
||||||
|
go run . 2>&1 | head -3 || true
|
||||||
|
```
|
||||||
|
Примечание: запуск `go run .` попытается прочитать `config.yaml` (не `.default`). Достаточно убедиться, что YAML парсится — можно временно скопировать в `config.yaml`, запустить и сразу остановить (Ctrl+C), либо положиться на то, что структура совпадает с типами из config.go. Не коммитить реальный `config.yaml` (он в `.gitignore`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add config.yaml.default
|
||||||
|
git commit -m "docs: пример конфига с секциями pachca и email"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Обновить README
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Записать README.md**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# http_logger
|
||||||
|
|
||||||
|
HTTP-логгер: принимает входящие HTTP-запросы и пересылает их в настроенный
|
||||||
|
канал уведомлений. Канал выбирается первым сегментом пути:
|
||||||
|
|
||||||
|
- `POST /telegram` → Telegram
|
||||||
|
- `POST /pachca` → Pachca (Пачка)
|
||||||
|
- `POST /email` → Email
|
||||||
|
|
||||||
|
Каналы настраиваются в `config.yaml` (пример — `config.yaml.default`).
|
||||||
|
Несконфигурированный путь возвращает `404`, ошибка отправки — `502`.
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.yaml.default config.yaml # отредактировать токены
|
||||||
|
./bin/http_logger
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md
|
||||||
|
git commit -m "docs: описать настраиваемые каналы в README"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (выполнено при написании плана)
|
||||||
|
|
||||||
|
**Покрытие спеки:**
|
||||||
|
- Путь = канал → Task 5 (makeHandler).
|
||||||
|
- Одна секция на тип → Task 2 (Config).
|
||||||
|
- Форматирование под канал → Task 4 (formatMarkdown/formatHTML).
|
||||||
|
- Канал не сконфигурирован → 404 → Task 5 (тест TestHandlerRouting).
|
||||||
|
- Обновление модуля notify → Task 1.
|
||||||
|
- ExtractRequest читает тело один раз, лимит 1024 → Task 3.
|
||||||
|
- Email: host из smtp_addr, PlainAuth, список получателей, subject default → Task 4 (BuildNotifiers).
|
||||||
|
- Тесты: ExtractRequest/GetRemoteAddr (Task 3), форматирование (Task 4), маршрутизация через мок (Task 5).
|
||||||
|
- Конфиг-пример + README → Task 6, 7.
|
||||||
|
|
||||||
|
**Согласованность типов:** `Notifier.Send(RequestData) error`, `RequestData`, `BuildNotifiers`, `makeHandler`, `formatMarkdown`/`formatHTML`/`formatPlain` — имена совпадают во всех задачах.
|
||||||
|
|
||||||
|
**Замечание по порядку:** Task 3 и Task 5 совместно правят `main.go`. Исполнителю рекомендовано коммитить request.go/тесты отдельно (Task 3), а правку main.go завершить в Task 5 — между этими задачами сборка `main.go` может быть временно сломана, это отражено в примечаниях.
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# Настраиваемые каналы уведомлений
|
||||||
|
|
||||||
|
Дата: 2026-06-14
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Сейчас `http_logger` умеет отправлять входящие HTTP-запросы только в Telegram —
|
||||||
|
канал жёстко зашит в `main.go`. Нужно дать возможность настраивать каналы
|
||||||
|
получения уведомлений через конфиг и добавить два новых: **Pachca (Пачка)** и
|
||||||
|
**Email**, используя библиотеку `gitea.mediatoday.ru/mt/notify`.
|
||||||
|
|
||||||
|
## Решения (согласованы с пользователем)
|
||||||
|
|
||||||
|
1. **Путь = канал.** Первый сегмент пути URL выбирает канал: `/telegram`,
|
||||||
|
`/pachca`, `/email`. Каждый путь шлёт ровно в свой канал. Сохраняет текущую
|
||||||
|
логику маршрутизации.
|
||||||
|
2. **Один экземпляр на тип.** В конфиге по одной секции на каждый тип канала.
|
||||||
|
3. **Форматирование под каждый канал.** Извлечение данных запроса — общее,
|
||||||
|
форматирование — на стороне канала (Telegram — markdown-блок, Email — HTML).
|
||||||
|
4. **Канал не сконфигурирован → `404`.**
|
||||||
|
5. Поднять модуль `notify` на актуальную версию по новому пути
|
||||||
|
`gitea.mediatoday.ru/mt/notify` (в старой версии нет Pachca/Email).
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
### Структура данных запроса
|
||||||
|
|
||||||
|
`BuildMessage` разделяется на две части: извлечение данных и форматирование.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// RequestData — извлечённые данные входящего запроса
|
||||||
|
type RequestData struct {
|
||||||
|
Time time.Time
|
||||||
|
Method string
|
||||||
|
Host string
|
||||||
|
URL string
|
||||||
|
RemoteAddr string
|
||||||
|
Header http.Header
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractRequest читает запрос один раз (тело с лимитом 1024 байт, как сейчас)
|
||||||
|
func ExtractRequest(r *http.Request) RequestData
|
||||||
|
```
|
||||||
|
|
||||||
|
Логика `GetRemoteAddr` и лимит тела (`> 1024` → `"too long"`) сохраняются.
|
||||||
|
|
||||||
|
### Интерфейс канала
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Notifier — канал отправки уведомлений
|
||||||
|
type Notifier interface {
|
||||||
|
Send(data RequestData) error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Реализации
|
||||||
|
|
||||||
|
Каждая реализация сама форматирует `RequestData` в свой формат.
|
||||||
|
|
||||||
|
- **`telegramNotifier{bot *notify.Bot, chatID int64}`**
|
||||||
|
Форматирует тело в ` ``` `-блок (как сейчас), отправляет `bot.SendTextMessage`
|
||||||
|
(ParseMode Markdown).
|
||||||
|
- **`pachcaNotifier{client *notify.Pachca, chatID int64}`**
|
||||||
|
Форматирует в markdown ` ``` `-блок, отправляет `client.SendMessage(chatID, text)`.
|
||||||
|
- **`emailNotifier{auth notify.SmtpAuth, from mail.Address, to []mail.Address, subject string}`**
|
||||||
|
Форматирует тело в `<pre>…</pre>` с HTML-экранированием содержимого, отправляет
|
||||||
|
`notify.SendEmailHTML(auth, body, subject, from, to...)`.
|
||||||
|
|
||||||
|
Каждая реализация выносится в отдельный файл (`telegram.go`, `pachca.go`,
|
||||||
|
`email.go`) либо группируется в `notifiers.go` — детали на этапе плана. Цель —
|
||||||
|
не раздувать `main.go`.
|
||||||
|
|
||||||
|
### Конфиг
|
||||||
|
|
||||||
|
Любую секцию можно опустить — тогда соответствующий путь даёт `404`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
listen_addresses:
|
||||||
|
- ":8080"
|
||||||
|
- "127.0.0.1:9090"
|
||||||
|
|
||||||
|
log_dir: "logs"
|
||||||
|
|
||||||
|
telegram:
|
||||||
|
token: "*****"
|
||||||
|
group_id: -10012345
|
||||||
|
disable_ipv6: true # опционально, по умолчанию true
|
||||||
|
|
||||||
|
pachca:
|
||||||
|
token: "*****"
|
||||||
|
chat_id: 12345
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp_addr: "smtp.example.com:587"
|
||||||
|
username: "user@example.com"
|
||||||
|
password: "*****"
|
||||||
|
from: "logger@example.com"
|
||||||
|
to:
|
||||||
|
- "alert@example.com"
|
||||||
|
subject: "HTTP Logger" # опционально, по умолчанию "HTTP Logger"
|
||||||
|
```
|
||||||
|
|
||||||
|
Структуры Go:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Config struct {
|
||||||
|
ListenAddresses []string `yaml:"listen_addresses"`
|
||||||
|
LogDir string `yaml:"log_dir"`
|
||||||
|
Telegram *ConfigTelegram `yaml:"telegram"`
|
||||||
|
Pachca *ConfigPachca `yaml:"pachca"`
|
||||||
|
Email *ConfigEmail `yaml:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigTelegram struct {
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
GroupID int64 `yaml:"group_id"`
|
||||||
|
DisableIPV6 *bool `yaml:"disable_ipv6"` // nil → true
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigPachca struct {
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
ChatID int64 `yaml:"chat_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigEmail struct {
|
||||||
|
SMTPAddr string `yaml:"smtp_addr"`
|
||||||
|
Username string `yaml:"username"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
From string `yaml:"from"`
|
||||||
|
To []string `yaml:"to"`
|
||||||
|
Subject string `yaml:"subject"` // "" → "HTTP Logger"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Замечание: исходный конфиг содержал опечатку `token:"*****"` (без пробела) и
|
||||||
|
имя типа `ConfigTelegraam` — исправляются в рамках работы.
|
||||||
|
|
||||||
|
### Сборка каналов на старте
|
||||||
|
|
||||||
|
```go
|
||||||
|
notifiers := map[string]Notifier{}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Для каждой заданной секции конфига строим соответствующий `Notifier` и кладём
|
||||||
|
в map под ключом-именем пути (`"telegram"`, `"pachca"`, `"email"`).
|
||||||
|
- Для email: хост для `smtp.PlainAuth` извлекается из `smtp_addr` через
|
||||||
|
`net.SplitHostPort`; `auth = smtp.PlainAuth("", username, password, host)`.
|
||||||
|
- Если ни одна секция не задана → `log.Fatal("не сконфигурирован ни один канал")`.
|
||||||
|
- Ошибка инициализации конкретного канала (например, `NewTelegram`) → `log.Fatal`.
|
||||||
|
|
||||||
|
### Маршрутизация в хендлере
|
||||||
|
|
||||||
|
```go
|
||||||
|
paths := strings.Split(r.URL.Path, "/") // "/telegram" → ["", "telegram"]
|
||||||
|
if len(paths) < 2 { 404 }
|
||||||
|
n, ok := notifiers[paths[1]]
|
||||||
|
if !ok { 404 } // неизвестный или несконфигурированный канал
|
||||||
|
data := ExtractRequest(r)
|
||||||
|
if err := n.Send(data); err != nil {
|
||||||
|
log.Println(err); 502 + err.Error()
|
||||||
|
} else {
|
||||||
|
200 + "OK"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Поведение кодов ответа (`404` / `502` / `200`) сохраняется как в текущей версии.
|
||||||
|
|
||||||
|
## Обработка ошибок
|
||||||
|
|
||||||
|
- Несконфигурированный/неизвестный путь → `404`.
|
||||||
|
- Ошибка отправки в канал → `502` + текст ошибки в теле (как сейчас).
|
||||||
|
- Ошибки конфигурации/инициализации каналов → `log.Fatal` на старте.
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
- `go.mod`: заменить `git.gm6.ru/icewind/notify` на
|
||||||
|
`gitea.mediatoday.ru/mt/notify v0.0.0-20260405185738-6f5db00fcb34`
|
||||||
|
(доступна в module cache). Обновить `go.sum` через `go mod tidy`.
|
||||||
|
- Новые импорты: `net/mail`, `net/smtp`, `html` (для экранирования HTML email).
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
- `ExtractRequest` — модульный тест: корректный разбор метода/хоста/URL/заголовков,
|
||||||
|
`GetRemoteAddr` (X-Forwarded-For → X-Real-IP → RemoteAddr), лимит тела `> 1024`.
|
||||||
|
- Форматирование каждого канала — тест, проверяющий обёртку (` ``` `-блок для
|
||||||
|
Telegram/Pachca, `<pre>` + экранирование для Email).
|
||||||
|
- Маршрутизация хендлера — тест через `httptest`: неизвестный путь → `404`,
|
||||||
|
известный канал с моком `Notifier` → `200`, ошибка `Send` → `502`. Интерфейс
|
||||||
|
`Notifier` позволяет подменить реальные каналы моком.
|
||||||
|
- Реальная отправка в Telegram/Pachca/Email не тестируется (внешние сервисы).
|
||||||
|
|
||||||
|
## Вне рамок (YAGNI)
|
||||||
|
|
||||||
|
- Broadcast «один запрос → все каналы».
|
||||||
|
- Несколько экземпляров одного типа канала.
|
||||||
|
- Именованные маршруты с произвольным набором каналов.
|
||||||
|
- Вложения/фото (библиотека умеет, но текущая задача — текст запроса).
|
||||||
14
go.mod
14
go.mod
@@ -1,10 +1,18 @@
|
|||||||
module http_logger
|
module http_logger
|
||||||
|
|
||||||
go 1.25.1
|
go 1.25.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.gm6.ru/icewind/notify v0.0.0-20251025181146-68310ac711a7
|
gitea.mediatoday.ru/mt/notify v0.0.0-20260405185738-6f5db00fcb34
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 // indirect
|
require (
|
||||||
|
gitea.mediatoday.ru/mt/image_table v0.0.0-20260403134557-a2b11128d8c9 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
|
||||||
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
|
github.com/scorredoira/email v0.0.0-20191107070024-dc7b732c55da // indirect
|
||||||
|
golang.org/x/image v0.34.0 // indirect
|
||||||
|
golang.org/x/text v0.32.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
16
go.sum
16
go.sum
@@ -1,7 +1,19 @@
|
|||||||
git.gm6.ru/icewind/notify v0.0.0-20251025181146-68310ac711a7 h1:F1SySndj7+K7dm0yOIq/OODRfgw3xpoJ7/mz06rNv0g=
|
gitea.mediatoday.ru/mt/image_table v0.0.0-20260403134557-a2b11128d8c9 h1:gtCO1GXbS6E3hWYWTCB3opvpDNV0W14KX86p/x5defQ=
|
||||||
git.gm6.ru/icewind/notify v0.0.0-20251025181146-68310ac711a7/go.mod h1:ydEvkfAmC16BxZL3+t0sq5+viZ3QNY/KH231RZ0sFwg=
|
gitea.mediatoday.ru/mt/image_table v0.0.0-20260403134557-a2b11128d8c9/go.mod h1:3+Vee5jTDPlMJaL/qwJkDFUI0ARxbzSmxRgrag/ZlMk=
|
||||||
|
gitea.mediatoday.ru/mt/notify v0.0.0-20260405185738-6f5db00fcb34 h1:Vi87778hM8bSFogImH2sjKacTJONuCveAzK+2PcpEak=
|
||||||
|
gitea.mediatoday.ru/mt/notify v0.0.0-20260405185738-6f5db00fcb34/go.mod h1:f173p7S2hvqGlc+YUhX7F2h9OJ8CiTNDH3v+gAn4id0=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/scorredoira/email v0.0.0-20191107070024-dc7b732c55da h1:hhmnjfzz7szp75AyXxn8tDfEA0oU4REQLmpuW6zNAOY=
|
||||||
|
github.com/scorredoira/email v0.0.0-20191107070024-dc7b732c55da/go.mod h1:Q5ljvYIBpukMH+wgB8kcPV1i9NX8TqU++8GgBKq3pt0=
|
||||||
|
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
|
||||||
|
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
|
||||||
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
|||||||
32
handler.go
Normal file
32
handler.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// makeHandler возвращает http-хендлер, маршрутизирующий по первому сегменту пути.
|
||||||
|
func makeHandler(notifiers map[string]Notifier) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths := strings.Split(r.URL.Path, "/")
|
||||||
|
if len(paths) < 2 || paths[1] == "" {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, ok := notifiers[paths[1]]
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := ExtractRequest(r)
|
||||||
|
if err := n.Send(data); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
_, _ = w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("OK"))
|
||||||
|
}
|
||||||
|
}
|
||||||
132
main.go
132
main.go
@@ -1,132 +1,30 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
|
|
||||||
"git.gm6.ru/icewind/notify"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config — структура для чтения YAML-файла
|
|
||||||
type Config struct {
|
|
||||||
ListenAddresses []string `yaml:"listen_addresses"`
|
|
||||||
LogDir string `yaml:"log_dir"`
|
|
||||||
Telegram *ConfigTelegraam `yaml:"telegram"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ConfigTelegraam struct {
|
|
||||||
Token string `yaml:"token"`
|
|
||||||
GroupID int64 `yaml:"group_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetRemoteAddr(r *http.Request) string {
|
|
||||||
// Сначала смотрим X-Forwarded-For
|
|
||||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
||||||
// X-Forwarded-For может содержать несколько IP через запятую
|
|
||||||
ips := strings.Split(xff, ",")
|
|
||||||
if len(ips) > 0 {
|
|
||||||
return strings.TrimSpace(ips[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Потом X-Real-IP
|
|
||||||
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
|
|
||||||
return realIP
|
|
||||||
}
|
|
||||||
|
|
||||||
// И в конце RemoteAddr
|
|
||||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
||||||
if err != nil {
|
|
||||||
return r.RemoteAddr
|
|
||||||
}
|
|
||||||
return host
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildMessage(r *http.Request) string {
|
|
||||||
t := time.Now()
|
|
||||||
|
|
||||||
var body []byte
|
|
||||||
|
|
||||||
if r.ContentLength > 1024 {
|
|
||||||
body = []byte("too long")
|
|
||||||
} else {
|
|
||||||
// Читаем тело запроса
|
|
||||||
body, _ = io.ReadAll(r.Body)
|
|
||||||
_ = r.Body.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Формируем содержимое
|
|
||||||
entry := fmt.Sprintf("[%s] %s %s%s\n", t.Format(time.RFC3339), r.Method, r.Host, r.URL.String())
|
|
||||||
entry += "RemoteAddr: " + GetRemoteAddr(r) + "\n"
|
|
||||||
entry += "Headers:\n"
|
|
||||||
for name, values := range r.Header {
|
|
||||||
for _, v := range values {
|
|
||||||
entry += fmt.Sprintf(" %s: %s\n", name, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entry += fmt.Sprintf("Body:\n%s\n", string(body))
|
|
||||||
entry += "----\n"
|
|
||||||
|
|
||||||
entry = "```" + entry + "```"
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.Println("Запуск приложения")
|
log.Println("Запуск приложения")
|
||||||
// Загружаем конфиг
|
|
||||||
cfg, err := loadConfig("config.yaml")
|
cfg, err := loadConfig("config.yaml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Ошибка загрузки конфига: %v", err)
|
log.Fatalf("Ошибка загрузки конфига: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Telegram == nil {
|
notifiers, err := BuildNotifiers(cfg)
|
||||||
log.Fatalf("не указана секция telegram")
|
|
||||||
}
|
|
||||||
|
|
||||||
tg, err := notify.NewTelegram(cfg.Telegram.Token, true)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatalf("Ошибка инициализации каналов: %v", err)
|
||||||
|
}
|
||||||
|
if len(notifiers) == 0 {
|
||||||
|
log.Fatal("не сконфигурирован ни один канал уведомлений")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Общий обработчик
|
http.HandleFunc("/", makeHandler(notifiers))
|
||||||
handler := func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
paths := strings.Split(r.URL.Path, "/")
|
|
||||||
switch {
|
|
||||||
case len(paths) < 2:
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
case paths[1] == "telegram":
|
|
||||||
default:
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := BuildMessage(r)
|
|
||||||
_, err = tg.SendTextMessage(cfg.Telegram.GroupID, entry)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
} else {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte("OK"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Регистрируем хендлер
|
|
||||||
http.HandleFunc("/", handler)
|
|
||||||
|
|
||||||
// Запускаем серверы для всех адресов
|
|
||||||
for _, addr := range cfg.ListenAddresses {
|
for _, addr := range cfg.ListenAddresses {
|
||||||
addr := addr // захватываем в область видимости
|
addr := addr
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("Слушаю %s ...", addr)
|
log.Printf("Слушаю %s ...", addr)
|
||||||
if err := http.ListenAndServe(addr, nil); err != nil {
|
if err := http.ListenAndServe(addr, nil); err != nil {
|
||||||
@@ -135,19 +33,5 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Блокируем main, чтобы программа не завершилась
|
|
||||||
select {}
|
select {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConfig читает YAML-конфиг из файла
|
|
||||||
func loadConfig(path string) (*Config, error) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var cfg Config
|
|
||||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &cfg, nil
|
|
||||||
}
|
|
||||||
|
|||||||
161
notifier.go
Normal file
161
notifier.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mediatoday.ru/mt/notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notifier — канал отправки уведомлений
|
||||||
|
type Notifier interface {
|
||||||
|
Send(data RequestData) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatPlain собирает текстовое тело без обёртки.
|
||||||
|
func formatPlain(d RequestData) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "[%s] %s %s%s\n", d.Time.Format("2006-01-02T15:04:05Z07:00"), d.Method, d.Host, d.URL)
|
||||||
|
fmt.Fprintf(&b, "RemoteAddr: %s\n", d.RemoteAddr)
|
||||||
|
b.WriteString("Headers:\n")
|
||||||
|
for name, values := range d.Header {
|
||||||
|
for _, v := range values {
|
||||||
|
fmt.Fprintf(&b, " %s: %s\n", name, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "Body:\n%s\n", d.Body)
|
||||||
|
b.WriteString("----\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatMarkdown — обёртка в код-блок для Telegram/Pachca.
|
||||||
|
func formatMarkdown(d RequestData) string {
|
||||||
|
return "```\n" + formatPlain(d) + "```"
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatHTML — обёртка в <pre> с экранированием для Email.
|
||||||
|
func formatHTML(d RequestData) string {
|
||||||
|
return "<pre>" + html.EscapeString(formatPlain(d)) + "</pre>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Telegram ---
|
||||||
|
|
||||||
|
type telegramNotifier struct {
|
||||||
|
bot *notify.Bot
|
||||||
|
chatID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *telegramNotifier) Send(d RequestData) error {
|
||||||
|
_, err := t.bot.SendTextMessage(t.chatID, formatMarkdown(d))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pachca (входящий вебхук) ---
|
||||||
|
|
||||||
|
type pachcaNotifier struct {
|
||||||
|
webhookURL string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pachcaNotifier) Send(d RequestData) error {
|
||||||
|
payload, err := json.Marshal(map[string]string{"message": formatMarkdown(d)})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := p.client.Post(p.webhookURL, "application/json", bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("pachca webhook: HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Email ---
|
||||||
|
|
||||||
|
type emailNotifier struct {
|
||||||
|
auth notify.SmtpAuth
|
||||||
|
from mail.Address
|
||||||
|
to []mail.Address
|
||||||
|
subject string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *emailNotifier) Send(d RequestData) error {
|
||||||
|
return notify.SendEmailHTML(e.auth, formatHTML(d), e.subject, e.from, e.to...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildNotifiers собирает map каналов из конфига. Добавляет только заданные секции.
|
||||||
|
func BuildNotifiers(cfg *Config) (map[string]Notifier, error) {
|
||||||
|
notifiers := map[string]Notifier{}
|
||||||
|
|
||||||
|
if cfg.Telegram != nil {
|
||||||
|
disableIPV6 := true
|
||||||
|
if cfg.Telegram.DisableIPV6 != nil {
|
||||||
|
disableIPV6 = *cfg.Telegram.DisableIPV6
|
||||||
|
}
|
||||||
|
bot, err := notify.NewTelegram(cfg.Telegram.Token, disableIPV6)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("telegram: %w", err)
|
||||||
|
}
|
||||||
|
notifiers["telegram"] = &telegramNotifier{bot: bot, chatID: cfg.Telegram.GroupID}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Pachca != nil {
|
||||||
|
if cfg.Pachca.WebhookURL == "" {
|
||||||
|
return nil, fmt.Errorf("pachca: не задан webhook_url")
|
||||||
|
}
|
||||||
|
notifiers["pachca"] = &pachcaNotifier{
|
||||||
|
webhookURL: cfg.Pachca.WebhookURL,
|
||||||
|
client: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Email != nil {
|
||||||
|
host, _, err := net.SplitHostPort(cfg.Email.SMTPAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("email smtp_addr: %w", err)
|
||||||
|
}
|
||||||
|
from, err := mail.ParseAddress(cfg.Email.From)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("email from: %w", err)
|
||||||
|
}
|
||||||
|
var to []mail.Address
|
||||||
|
for _, addr := range cfg.Email.To {
|
||||||
|
a, err := mail.ParseAddress(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("email to %q: %w", addr, err)
|
||||||
|
}
|
||||||
|
to = append(to, *a)
|
||||||
|
}
|
||||||
|
if len(to) == 0 {
|
||||||
|
return nil, fmt.Errorf("email: не задан ни один получатель (to)")
|
||||||
|
}
|
||||||
|
subject := cfg.Email.Subject
|
||||||
|
if subject == "" {
|
||||||
|
subject = "HTTP Logger"
|
||||||
|
}
|
||||||
|
notifiers["email"] = &emailNotifier{
|
||||||
|
auth: notify.SmtpAuth{
|
||||||
|
Addr: cfg.Email.SMTPAddr,
|
||||||
|
Auth: smtp.PlainAuth("", cfg.Email.Username, cfg.Email.Password, host),
|
||||||
|
},
|
||||||
|
from: *from,
|
||||||
|
to: to,
|
||||||
|
subject: subject,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return notifiers, nil
|
||||||
|
}
|
||||||
154
notifier_test.go
Normal file
154
notifier_test.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errSend = errors.New("send failed")
|
||||||
|
|
||||||
|
func sampleData() RequestData {
|
||||||
|
return RequestData{
|
||||||
|
Time: time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC),
|
||||||
|
Method: "POST",
|
||||||
|
Host: "example.com",
|
||||||
|
URL: "/telegram",
|
||||||
|
RemoteAddr: "1.2.3.4",
|
||||||
|
Header: http.Header{"X-Test": []string{"v"}},
|
||||||
|
Body: "payload<>",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatMarkdown(t *testing.T) {
|
||||||
|
out := formatMarkdown(sampleData())
|
||||||
|
if !strings.HasPrefix(out, "```\n") || !strings.HasSuffix(out, "```") {
|
||||||
|
// Перевод строки после открывающего фенса обязателен: иначе markdown
|
||||||
|
// в Pachca/Telegram съедает первую строку как указатель языка, и путь
|
||||||
|
// запроса (METHOD host/url) пропадает из уведомления.
|
||||||
|
t.Fatalf("expected ```\\n wrapping, got: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "/telegram") {
|
||||||
|
t.Errorf("missing request path/url: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "POST") || !strings.Contains(out, "1.2.3.4") {
|
||||||
|
t.Errorf("missing fields: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "X-Test: v") {
|
||||||
|
t.Errorf("missing header: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatHTML(t *testing.T) {
|
||||||
|
out := formatHTML(sampleData())
|
||||||
|
if !strings.Contains(out, "<pre>") || !strings.Contains(out, "</pre>") {
|
||||||
|
t.Fatalf("expected <pre> wrapping, got: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "payload<>") {
|
||||||
|
t.Errorf("body not escaped: %q", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "payload<>") {
|
||||||
|
t.Errorf("unescaped body present: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockNotifier struct {
|
||||||
|
called bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockNotifier) Send(d RequestData) error {
|
||||||
|
m.called = true
|
||||||
|
return m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerRouting(t *testing.T) {
|
||||||
|
tg := &mockNotifier{}
|
||||||
|
h := makeHandler(map[string]Notifier{"telegram": tg})
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h(rec, httptest.NewRequest("POST", "/telegram", strings.NewReader("x")))
|
||||||
|
if rec.Code != 200 || !tg.called {
|
||||||
|
t.Fatalf("telegram: code=%d called=%v", rec.Code, tg.called)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h(rec, httptest.NewRequest("POST", "/unknown", strings.NewReader("x")))
|
||||||
|
if rec.Code != 404 {
|
||||||
|
t.Fatalf("unknown: code=%d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h(rec, httptest.NewRequest("POST", "/", nil))
|
||||||
|
if rec.Code != 404 {
|
||||||
|
t.Fatalf("empty: code=%d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerSendError(t *testing.T) {
|
||||||
|
failing := &mockNotifier{err: errSend}
|
||||||
|
h := makeHandler(map[string]Notifier{"telegram": failing})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h(rec, httptest.NewRequest("POST", "/telegram", strings.NewReader("x")))
|
||||||
|
if rec.Code != 502 {
|
||||||
|
t.Fatalf("expected 502, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildNotifiersEmpty(t *testing.T) {
|
||||||
|
n, err := BuildNotifiers(&Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
if len(n) != 0 {
|
||||||
|
t.Fatalf("expected empty map, got %d", len(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildNotifiersPachcaMissingURL(t *testing.T) {
|
||||||
|
_, err := BuildNotifiers(&Config{Pachca: &ConfigPachca{}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty webhook_url")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildNotifiersEmailNoRecipients(t *testing.T) {
|
||||||
|
_, err := BuildNotifiers(&Config{Email: &ConfigEmail{
|
||||||
|
SMTPAddr: "smtp.example.com:587",
|
||||||
|
From: "logger@example.com",
|
||||||
|
To: nil,
|
||||||
|
}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for zero recipients")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildNotifiersEmailBadSMTPAddr(t *testing.T) {
|
||||||
|
_, err := BuildNotifiers(&Config{Email: &ConfigEmail{
|
||||||
|
SMTPAddr: "no-port",
|
||||||
|
From: "logger@example.com",
|
||||||
|
To: []string{"a@example.com"},
|
||||||
|
}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for smtp_addr without port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildNotifiersEmailOK(t *testing.T) {
|
||||||
|
n, err := BuildNotifiers(&Config{Email: &ConfigEmail{
|
||||||
|
SMTPAddr: "smtp.example.com:587",
|
||||||
|
Username: "u",
|
||||||
|
Password: "p",
|
||||||
|
From: "logger@example.com",
|
||||||
|
To: []string{"a@example.com"},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := n["email"]; !ok {
|
||||||
|
t.Fatal("email notifier not built")
|
||||||
|
}
|
||||||
|
}
|
||||||
57
request.go
Normal file
57
request.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestData — извлечённые данные входящего запроса
|
||||||
|
type RequestData struct {
|
||||||
|
Time time.Time
|
||||||
|
Method string
|
||||||
|
Host string
|
||||||
|
URL string
|
||||||
|
RemoteAddr string
|
||||||
|
Header http.Header
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRemoteAddr(r *http.Request) string {
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
ips := strings.Split(xff, ",")
|
||||||
|
if len(ips) > 0 {
|
||||||
|
return strings.TrimSpace(ips[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
|
||||||
|
return realIP
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractRequest читает запрос один раз и возвращает нейтральные данные.
|
||||||
|
func ExtractRequest(r *http.Request) RequestData {
|
||||||
|
var body []byte
|
||||||
|
if r.ContentLength > 1024 {
|
||||||
|
body = []byte("too long")
|
||||||
|
} else {
|
||||||
|
body, _ = io.ReadAll(r.Body)
|
||||||
|
}
|
||||||
|
_ = r.Body.Close()
|
||||||
|
return RequestData{
|
||||||
|
Time: time.Now(),
|
||||||
|
Method: r.Method,
|
||||||
|
Host: r.Host,
|
||||||
|
URL: r.URL.RequestURI(),
|
||||||
|
RemoteAddr: GetRemoteAddr(r),
|
||||||
|
Header: r.Header,
|
||||||
|
Body: string(body),
|
||||||
|
}
|
||||||
|
}
|
||||||
58
request_test.go
Normal file
58
request_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetRemoteAddr(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
set func(r *http.Request)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"xff", func(r *http.Request) { r.Header.Set("X-Forwarded-For", "1.1.1.1, 2.2.2.2") }, "1.1.1.1"},
|
||||||
|
{"realip", func(r *http.Request) { r.Header.Set("X-Real-IP", "3.3.3.3") }, "3.3.3.3"},
|
||||||
|
{"remoteaddr", func(r *http.Request) { r.RemoteAddr = "4.4.4.4:5678" }, "4.4.4.4"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("GET", "/telegram", nil)
|
||||||
|
r.RemoteAddr = ""
|
||||||
|
tt.set(r)
|
||||||
|
if got := GetRemoteAddr(r); got != tt.want {
|
||||||
|
t.Fatalf("got %q want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRequest(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("POST", "http://example.com/telegram?x=1", strings.NewReader("hello"))
|
||||||
|
r.Header.Set("X-Real-IP", "9.9.9.9")
|
||||||
|
data := ExtractRequest(r)
|
||||||
|
if data.Method != "POST" {
|
||||||
|
t.Errorf("method = %q", data.Method)
|
||||||
|
}
|
||||||
|
if data.RemoteAddr != "9.9.9.9" {
|
||||||
|
t.Errorf("remoteaddr = %q", data.RemoteAddr)
|
||||||
|
}
|
||||||
|
if data.Body != "hello" {
|
||||||
|
t.Errorf("body = %q", data.Body)
|
||||||
|
}
|
||||||
|
if data.URL != "/telegram?x=1" {
|
||||||
|
t.Errorf("url = %q", data.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRequestBodyLimit(t *testing.T) {
|
||||||
|
big := strings.Repeat("a", 2000)
|
||||||
|
r := httptest.NewRequest("POST", "/telegram", strings.NewReader(big))
|
||||||
|
r.ContentLength = int64(len(big))
|
||||||
|
data := ExtractRequest(r)
|
||||||
|
if data.Body != "too long" {
|
||||||
|
t.Errorf("expected 'too long', got len %d", len(data.Body))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user