Merge: настраиваемые каналы уведомлений (Telegram/Pachca/Email)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
*.yaml
|
||||
bin/http_logger
|
||||
/http_logger
|
||||
*.out
|
||||
18
README.md
18
README.md
@@ -1,2 +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
|
||||
|
||||
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"
|
||||
|
||||
# Любую секцию можно опустить — тогда соответствующий путь вернёт 404.
|
||||
|
||||
telegram:
|
||||
token:"*****"
|
||||
token: "*****"
|
||||
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" # опционально
|
||||
|
||||
14
go.mod
14
go.mod
@@ -1,10 +1,18 @@
|
||||
module http_logger
|
||||
|
||||
go 1.25.1
|
||||
go 1.25.4
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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=
|
||||
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 h1:gtCO1GXbS6E3hWYWTCB3opvpDNV0W14KX86p/x5defQ=
|
||||
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/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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"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() {
|
||||
log.Println("Запуск приложения")
|
||||
// Загружаем конфиг
|
||||
|
||||
cfg, err := loadConfig("config.yaml")
|
||||
if err != nil {
|
||||
log.Fatalf("Ошибка загрузки конфига: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Telegram == nil {
|
||||
log.Fatalf("не указана секция telegram")
|
||||
}
|
||||
|
||||
tg, err := notify.NewTelegram(cfg.Telegram.Token, true)
|
||||
notifiers, err := BuildNotifiers(cfg)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
log.Fatalf("Ошибка инициализации каналов: %v", err)
|
||||
}
|
||||
if len(notifiers) == 0 {
|
||||
log.Fatal("не сконфигурирован ни один канал уведомлений")
|
||||
}
|
||||
|
||||
// Общий обработчик
|
||||
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
|
||||
}
|
||||
http.HandleFunc("/", makeHandler(notifiers))
|
||||
|
||||
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 {
|
||||
addr := addr // захватываем в область видимости
|
||||
addr := addr
|
||||
go func() {
|
||||
log.Printf("Слушаю %s ...", addr)
|
||||
if err := http.ListenAndServe(addr, nil); err != nil {
|
||||
@@ -135,19 +33,5 @@ func main() {
|
||||
}()
|
||||
}
|
||||
|
||||
// Блокируем main, чтобы программа не завершилась
|
||||
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 "```" + 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
148
notifier_test.go
Normal 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<>") {
|
||||
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