400 lines
10 KiB
Go
Executable file
400 lines
10 KiB
Go
Executable file
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/tls"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"net"
|
||
"net/http"
|
||
"net/smtp"
|
||
"net/url"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"golang.org/x/net/proxy"
|
||
)
|
||
|
||
/* ALERT DISPATCHER */
|
||
|
||
// sendAlert отправляет сообщение во все настроенные каналы
|
||
func sendAlert(message string) error {
|
||
var errs []string
|
||
|
||
if err := sendTelegramAlert(message); err != nil {
|
||
log.Printf("[Alert] Telegram error: %v", err)
|
||
errs = append(errs, "telegram: "+err.Error())
|
||
}
|
||
|
||
if mattermostURL != "" && mattermostBotToken != "" && mattermostChannelID != "" {
|
||
mmMessage := htmlToMarkdown(message)
|
||
if err := sendMattermostAlert(mmMessage); err != nil {
|
||
log.Printf("[Alert] Mattermost error: %v", err)
|
||
errs = append(errs, "mattermost: "+err.Error())
|
||
}
|
||
}
|
||
|
||
if smtpHost != "" && smtpFrom != "" && smtpTo != "" {
|
||
plainMessage := stripHTMLTags(message)
|
||
if err := sendEmailAlert(plainMessage); err != nil {
|
||
log.Printf("[Alert] Email error: %v", err)
|
||
errs = append(errs, "email: "+err.Error())
|
||
}
|
||
}
|
||
|
||
if len(errs) > 0 {
|
||
return fmt.Errorf("some alerts failed: %s", strings.Join(errs, "; "))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
/* TELEGRAM */
|
||
|
||
// buildTelegramClient пробует HTTP-прокси, затем SOCKS5, затем прямое подключение
|
||
func buildTelegramClient() *http.Client {
|
||
timeout := 20 * time.Second
|
||
|
||
// 1. HTTP прокси
|
||
if telegramHTTPProxy != "" {
|
||
proxyURL, err := url.Parse(telegramHTTPProxy)
|
||
if err == nil {
|
||
client := &http.Client{
|
||
Timeout: timeout,
|
||
Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
||
}
|
||
if checkTelegramConnectivity(client) {
|
||
log.Println("[Telegram] Using HTTP proxy:", telegramHTTPProxy)
|
||
return client
|
||
}
|
||
log.Println("[Telegram] HTTP proxy unavailable, trying SOCKS5")
|
||
} else {
|
||
log.Printf("[Telegram] Invalid HTTP proxy URL: %v", err)
|
||
}
|
||
}
|
||
|
||
// 2. SOCKS5 прокси
|
||
if telegramSocks5Proxy != "" {
|
||
var auth *proxy.Auth
|
||
if telegramSocks5User != "" {
|
||
auth = &proxy.Auth{
|
||
User: telegramSocks5User,
|
||
Password: telegramSocks5Password,
|
||
}
|
||
}
|
||
dialer, err := proxy.SOCKS5("tcp", telegramSocks5Proxy, auth, proxy.Direct)
|
||
if err == nil {
|
||
transport := &http.Transport{
|
||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||
return dialer.Dial(network, addr)
|
||
},
|
||
}
|
||
client := &http.Client{Timeout: timeout, Transport: transport}
|
||
if checkTelegramConnectivity(client) {
|
||
log.Println("[Telegram] Using SOCKS5 proxy:", telegramSocks5Proxy)
|
||
return client
|
||
}
|
||
log.Println("[Telegram] SOCKS5 proxy unavailable, using direct connection")
|
||
} else {
|
||
log.Printf("[Telegram] Failed to create SOCKS5 dialer: %v", err)
|
||
}
|
||
}
|
||
|
||
// 3. Прямое подключение
|
||
log.Println("[Telegram] Using direct connection")
|
||
return &http.Client{Timeout: timeout}
|
||
}
|
||
|
||
// checkTelegramConnectivity проверяет доступность Telegram API через данный клиент
|
||
func checkTelegramConnectivity(client *http.Client) bool {
|
||
resp, err := client.Get("https://api.telegram.org")
|
||
if err != nil {
|
||
return false
|
||
}
|
||
resp.Body.Close()
|
||
return true
|
||
}
|
||
|
||
func sendTelegramAlert(message string) error {
|
||
client := buildTelegramClient()
|
||
|
||
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", telegramBotToken)
|
||
|
||
payload := map[string]interface{}{
|
||
"chat_id": telegramChatID,
|
||
"text": message,
|
||
"parse_mode": "HTML",
|
||
}
|
||
|
||
jsonData, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
resp, err := client.Post(apiURL, "application/json", bytes.NewBuffer(jsonData))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return fmt.Errorf("Telegram API returned status %s", resp.Status)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
/* MATTERMOST */
|
||
|
||
func sendMattermostAlert(message string) error {
|
||
apiURL := fmt.Sprintf("%s/api/v4/posts", strings.TrimRight(mattermostURL, "/"))
|
||
|
||
payload := map[string]string{
|
||
"channel_id": mattermostChannelID,
|
||
"message": message,
|
||
}
|
||
|
||
jsonData, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
req, err := http.NewRequest(http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
req.Header.Set("Authorization", "Bearer "+mattermostBotToken)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
resp, err := httpClient.Do(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusCreated {
|
||
return fmt.Errorf("Mattermost API returned status %s", resp.Status)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
/* EMAIL */
|
||
|
||
func sendEmailAlert(message string) error {
|
||
addr := fmt.Sprintf("%s:%d", smtpHost, smtpPort)
|
||
|
||
subject := "WAF Monitor Alert"
|
||
body := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
|
||
smtpFrom, smtpTo, subject, message)
|
||
|
||
to := strings.Split(smtpTo, ",")
|
||
for i := range to {
|
||
to[i] = strings.TrimSpace(to[i])
|
||
}
|
||
|
||
if smtpSkipTLS {
|
||
// STARTTLS с отключённой проверкой сертификата
|
||
tlsConfig := &tls.Config{
|
||
InsecureSkipVerify: true,
|
||
ServerName: smtpHost,
|
||
}
|
||
client, err := smtp.Dial(addr)
|
||
if err != nil {
|
||
return fmt.Errorf("smtp.Dial: %w", err)
|
||
}
|
||
defer client.Close()
|
||
|
||
if err := client.StartTLS(tlsConfig); err != nil {
|
||
return fmt.Errorf("smtp.StartTLS: %w", err)
|
||
}
|
||
|
||
if smtpUser != "" && smtpPassword != "" {
|
||
var authMech smtp.Auth
|
||
|
||
if ok, ext := client.Extension("AUTH"); ok {
|
||
log.Printf("[Email] Server supported AUTH mechanisms: %s", ext)
|
||
switch {
|
||
case strings.Contains(ext, "LOGIN"):
|
||
log.Printf("[Email] Using LOGIN auth")
|
||
authMech = loginAuth(smtpUser, smtpPassword)
|
||
default:
|
||
log.Printf("[Email] Using PLAIN auth")
|
||
authMech = smtp.PlainAuth("", smtpUser, smtpPassword, smtpHost)
|
||
}
|
||
} else {
|
||
authMech = smtp.PlainAuth("", smtpUser, smtpPassword, smtpHost)
|
||
}
|
||
|
||
if err := client.Auth(authMech); err != nil {
|
||
return fmt.Errorf("smtp.Auth: %w", err)
|
||
}
|
||
}
|
||
|
||
if err := client.Mail(smtpFrom); err != nil {
|
||
return fmt.Errorf("smtp.Mail: %w", err)
|
||
}
|
||
for _, recipient := range to {
|
||
if err := client.Rcpt(recipient); err != nil {
|
||
return fmt.Errorf("smtp.Rcpt: %w", err)
|
||
}
|
||
}
|
||
w, err := client.Data()
|
||
if err != nil {
|
||
return fmt.Errorf("smtp.Data: %w", err)
|
||
}
|
||
if _, err := w.Write([]byte(body)); err != nil {
|
||
return fmt.Errorf("smtp write: %w", err)
|
||
}
|
||
return w.Close()
|
||
}
|
||
|
||
// Стандартная отправка с проверкой сертификата
|
||
var auth smtp.Auth
|
||
if smtpUser != "" && smtpPassword != "" {
|
||
auth = smtp.PlainAuth("", smtpUser, smtpPassword, smtpHost)
|
||
}
|
||
if err := smtp.SendMail(addr, auth, smtpFrom, to, []byte(body)); err != nil {
|
||
return fmt.Errorf("smtp.SendMail: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
/* HELPERS */
|
||
|
||
// htmlToMarkdown конвертирует базовые HTML теги Telegram в Markdown для Mattermost
|
||
func htmlToMarkdown(s string) string {
|
||
r := strings.NewReplacer(
|
||
"<b>", "**", "</b>", "**",
|
||
"<i>", "*", "</i>", "*",
|
||
"<code>", "`", "</code>", "`",
|
||
"<pre>", "```\n", "</pre>", "\n```",
|
||
)
|
||
return r.Replace(s)
|
||
}
|
||
|
||
// stripHTMLTags убирает HTML теги для plain text email
|
||
func stripHTMLTags(s string) string {
|
||
r := strings.NewReplacer(
|
||
"<b>", "", "</b>", "",
|
||
"<i>", "", "</i>", "",
|
||
"<code>", "", "</code>", "",
|
||
"<pre>", "", "</pre>", "",
|
||
)
|
||
return r.Replace(s)
|
||
}
|
||
|
||
/* TELEGRAM MESSAGE FORMATTERS */
|
||
|
||
func formatTelegramMessage(added, removed []string, ipToSIDs map[string][]int64, sidToDomain map[int64]string) string {
|
||
var msg strings.Builder
|
||
msg.WriteString("<b>🔄 Обновление PTAF Whitelist DosGate</b>\n\n")
|
||
|
||
if len(added) > 0 {
|
||
msg.WriteString(fmt.Sprintf("<b>✅ Добавлено origin IP (%d):</b>\n\n", len(added)))
|
||
limit := len(added)
|
||
if limit > 20 {
|
||
limit = 20
|
||
}
|
||
for i := 0; i < limit; i++ {
|
||
ip := added[i]
|
||
if sids, ok := ipToSIDs[ip]; ok && len(sids) > 0 {
|
||
sid := sids[0]
|
||
domain := sidToDomain[sid]
|
||
msg.WriteString(fmt.Sprintf("%s (SID: %d, %s)\n", ip, sid, domain))
|
||
} else {
|
||
msg.WriteString(ip + "\n")
|
||
}
|
||
}
|
||
if len(added) > 20 {
|
||
msg.WriteString(fmt.Sprintf("... и ещё %d\n", len(added)-20))
|
||
}
|
||
msg.WriteString("\n")
|
||
}
|
||
|
||
if len(removed) > 0 {
|
||
msg.WriteString(fmt.Sprintf("<b>❌ Удалено origin IP (%d):</b>\n\n", len(removed)))
|
||
limit := len(removed)
|
||
if limit > 20 {
|
||
limit = 20
|
||
}
|
||
for i := 0; i < limit; i++ {
|
||
msg.WriteString(removed[i] + "\n")
|
||
}
|
||
if len(removed) > 20 {
|
||
msg.WriteString(fmt.Sprintf("... и ещё %d\n", len(removed)-20))
|
||
}
|
||
msg.WriteString("\n")
|
||
}
|
||
|
||
whitelistPath := filepath.Join(gitRepoPath, whitelistFile)
|
||
currentIPs, err := readWhitelistFile(whitelistPath)
|
||
totalCount := len(currentIPs)
|
||
if err != nil {
|
||
totalCount = 0
|
||
}
|
||
|
||
msg.WriteString(fmt.Sprintf("<b>📊 Итого в whitelist:</b> %d IP адресов", totalCount))
|
||
|
||
return msg.String()
|
||
}
|
||
|
||
func formatCommitMessage(added, removed []string) string {
|
||
var parts []string
|
||
|
||
if len(added) > 0 {
|
||
parts = append(parts, fmt.Sprintf("Added %d IP(s)", len(added)))
|
||
}
|
||
if len(removed) > 0 {
|
||
parts = append(parts, fmt.Sprintf("Removed %d IP(s)", len(removed)))
|
||
}
|
||
|
||
msg := "Auto-update WAF whitelist: " + strings.Join(parts, ", ")
|
||
|
||
if len(added)+len(removed) <= 10 {
|
||
var details []string
|
||
for _, ip := range added {
|
||
details = append(details, "+"+ip)
|
||
}
|
||
for _, ip := range removed {
|
||
details = append(details, "-"+ip)
|
||
}
|
||
if len(details) > 0 {
|
||
msg += "\n\n" + strings.Join(details, "\n")
|
||
}
|
||
}
|
||
|
||
return msg
|
||
}
|
||
|
||
/* LOGIN AUTH */
|
||
|
||
// loginAuth реализует механизм аутентификации LOGIN для SMTP
|
||
type loginAuthType struct {
|
||
username, password string
|
||
}
|
||
|
||
func loginAuth(username, password string) smtp.Auth {
|
||
return &loginAuthType{username, password}
|
||
}
|
||
|
||
func (a *loginAuthType) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||
return "LOGIN", nil, nil
|
||
}
|
||
|
||
func (a *loginAuthType) Next(fromServer []byte, more bool) ([]byte, error) {
|
||
if more {
|
||
switch string(fromServer) {
|
||
case "Username:":
|
||
return []byte(a.username), nil
|
||
case "Password:":
|
||
return []byte(a.password), nil
|
||
default:
|
||
return nil, fmt.Errorf("unexpected server challenge: %s", fromServer)
|
||
}
|
||
}
|
||
return nil, nil
|
||
}
|