Compare commits

..

17 Commits

Author SHA1 Message Date
Vladimir V Maksimov
6ec0d75f0f Merge: настраиваемые каналы уведомлений (Telegram/Pachca/Email)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:24:05 +03:00
Vladimir V Maksimov
7cf5bca8d2 fix: всегда закрывать тело запроса в ExtractRequest
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:22:35 +03:00
Vladimir V Maksimov
118bbb9261 build: go mod tidy — убрать старую зависимость git.gm6.ru; gitignore корневой бинарь
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:19:18 +03:00
Vladimir V Maksimov
1409bb265c fix: восстановить проверку 'ни один канал не сконфигурирован'
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:17:46 +03:00
Vladimir V Maksimov
1a54862e91 feat: пример конфига, README; чистка и тесты BuildNotifiers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:16:42 +03:00
Vladimir V Maksimov
a21bb73175 feat: настраиваемые каналы уведомлений (telegram/pachca/email) с маршрутизацией по пути
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 11:10:44 +03:00
Vladimir V Maksimov
cfc9e23697 refactor: вынести конфиг в config.go, добавить секции pachca/email
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 11:06:23 +03:00
Vladimir V Maksimov
a6d2f82633 docs: Pachca через входящий вебхук вместо API-токена
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:05:14 +03:00
Vladimir V Maksimov
a638dd9441 build: перейти на gitea.mediatoday.ru/mt/notify
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 11:02:16 +03:00
Vladimir V Maksimov
8a431ff02f docs: план реализации настраиваемых каналов уведомлений
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:57:16 +03:00
Vladimir V Maksimov
f63522f86e docs: спецификация настраиваемых каналов уведомлений (Telegram/Pachca/Email)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:53:43 +03:00
cf3ec88bb1 real remote addr 2025-10-26 13:14:31 +03:00
842444b70b telegram 2025-10-26 13:11:10 +03:00
e8353f7e28 telegram 2025-10-26 13:01:35 +03:00
Vladimir V Maksimov
2f9deb6997 files fix 2025-10-26 02:13:11 +03:00
Vladimir V Maksimov
565f4ce4fc Merge branch 'master' 2025-10-26 02:11:17 +03:00
bdb6574cf7 Initial commit 2025-10-25 22:46:26 +00:00
16 changed files with 1641 additions and 100 deletions

4
.gitignore vendored
View File

@@ -1,2 +1,4 @@
*.yaml *.yaml
bin/http_logger bin/http_logger
/http_logger
*.out

18
LICENSE Normal file
View File

@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2025 icewind
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
# 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

View File

@@ -1,10 +0,0 @@
2025/10/26 01:40:52 Ошибка загрузки конфига: open config.yaml: no such file or directory
2025/10/26 01:41:33 Ошибка загрузки конфига: open config.yaml: no such file or directory
2025/10/26 01:42:54 Запуск приложения
2025/10/26 01:42:54 Ошибка загрузки конфига: open config.yaml: no such file or directory
2025/10/26 01:43:36 Запуск приложения
2025/10/26 01:43:36 Слушаю 127.0.0.1:7001 ...
2025/10/26 01:43:50 Запуск приложения
2025/10/26 01:43:50 Слушаю 127.0.0.1:7001 ...
2025/10/26 01:43:54 Запуск приложения
2025/10/26 01:43:54 Слушаю 127.0.0.1:7001 ...

48
config.go Normal file
View 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
}

View File

@@ -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" # опционально

View 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 14), а правку 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&lt;&gt;") {
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` может быть временно сломана, это отражено в примечаниях.

View File

@@ -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
View File

@@ -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
View File

@@ -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
View 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"))
}
}

91
main.go
View File

@@ -1,91 +1,30 @@
package main package main
import ( import (
"fmt"
"io"
"log" "log"
"net/http" "net/http"
"os"
"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 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) {
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 += "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 + "```"
_, 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 {
@@ -94,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
View 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 "```" + 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
}

148
notifier_test.go Normal file
View File

@@ -0,0 +1,148 @@
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, "```") || !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&lt;&gt;") {
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
View 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
View 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))
}
}