diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..6db5164 --- /dev/null +++ b/handler.go @@ -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")) + } +} diff --git a/main.go b/main.go index 0a1cac5..1dc7804 100644 --- a/main.go +++ b/main.go @@ -1,117 +1,30 @@ package main import ( - "fmt" - "io" "log" - "net" "net/http" - "strings" - "time" - - "gitea.mediatoday.ru/mt/notify" ) -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 { @@ -120,6 +33,5 @@ func main() { }() } - // Блокируем main, чтобы программа не завершилась select {} } diff --git a/notifier.go b/notifier.go new file mode 100644 index 0000000..0b29ff8 --- /dev/null +++ b/notifier.go @@ -0,0 +1,165 @@ +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 — обёртка в
с экранированием для Email.
+func formatHTML(d RequestData) string {
+ return "" + html.EscapeString(formatPlain(d)) + "
"
+}
+
+// --- 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...)
+}
+
+func splitHostPortLenient(addr string) (host, port string, err error) {
+ return net.SplitHostPort(addr)
+}
+
+// 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 := 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
+}
diff --git a/notifier_test.go b/notifier_test.go
new file mode 100644
index 0000000..5396950
--- /dev/null
+++ b/notifier_test.go
@@ -0,0 +1,93 @@
+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, "") || !strings.Contains(out, "
") {
+ t.Fatalf("expected 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)
+ }
+}
diff --git a/request.go b/request.go
new file mode 100644
index 0000000..96ce966
--- /dev/null
+++ b/request.go
@@ -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),
+ }
+}
diff --git a/request_test.go b/request_test.go
new file mode 100644
index 0000000..61c272b
--- /dev/null
+++ b/request_test.go
@@ -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))
+ }
+}