Initial import: nasmon health monitor, split out of home_nas

Go service (nasmon/) plus nfpm packaging (packaging/), carried over from
the home_nas monorepo. Adds rpm packaging alongside the existing deb
build (Fedora/RHEL/Rocky dependency names via nfpm overrides, per-format
systemd unit path, dpkg+rpm-compatible maintainer scripts) and a Gitea
Actions workflow that builds both formats on every push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 10:24:34 +03:00
co-authored by Claude Sonnet 5
commit 09159a5f31
61 changed files with 7882 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
package alert
import (
"crypto/tls"
"errors"
"fmt"
"mime"
"net"
"net/smtp"
"strings"
"time"
"nasmon/internal/config"
)
// SendMail delivers a plain-text message using the configured SMTP account.
func SendMail(cfg config.SMTP, subject, body string) error {
if cfg.Host == "" {
return errors.New("SMTP host is not configured")
}
if len(cfg.To) == 0 {
return errors.New("no recipients configured")
}
from := cfg.From
if from == "" {
from = cfg.User
}
if from == "" {
return errors.New("sender address (from) is not configured")
}
addr := net.JoinHostPort(cfg.Host, fmt.Sprint(cfg.Port))
dialer := net.Dialer{Timeout: 20 * time.Second}
var c *smtp.Client
switch cfg.Security {
case "tls":
conn, err := tls.DialWithDialer(&dialer, "tcp", addr, &tls.Config{ServerName: cfg.Host})
if err != nil {
return fmt.Errorf("tls connect: %w", err)
}
c, err = smtp.NewClient(conn, cfg.Host)
if err != nil {
conn.Close()
return err
}
default:
conn, err := dialer.Dial("tcp", addr)
if err != nil {
return fmt.Errorf("connect: %w", err)
}
conn.SetDeadline(time.Now().Add(60 * time.Second))
c, err = smtp.NewClient(conn, cfg.Host)
if err != nil {
conn.Close()
return err
}
if cfg.Security == "starttls" {
if ok, _ := c.Extension("STARTTLS"); !ok {
c.Close()
return errors.New("server does not support STARTTLS")
}
if err := c.StartTLS(&tls.Config{ServerName: cfg.Host}); err != nil {
c.Close()
return fmt.Errorf("starttls: %w", err)
}
}
}
defer c.Close()
if cfg.User != "" {
ok, mechs := c.Extension("AUTH")
var auth smtp.Auth
switch {
case ok && strings.Contains(mechs, "PLAIN"):
auth = smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host)
case ok && strings.Contains(mechs, "LOGIN"):
auth = loginAuth{cfg.User, cfg.Pass}
case ok && strings.Contains(mechs, "CRAM-MD5"):
auth = smtp.CRAMMD5Auth(cfg.User, cfg.Pass)
default:
auth = smtp.PlainAuth("", cfg.User, cfg.Pass, cfg.Host)
}
if err := c.Auth(auth); err != nil {
return fmt.Errorf("auth: %w", err)
}
}
if err := c.Mail(from); err != nil {
return fmt.Errorf("MAIL FROM: %w", err)
}
for _, to := range cfg.To {
if err := c.Rcpt(to); err != nil {
return fmt.Errorf("RCPT TO %s: %w", to, err)
}
}
w, err := c.Data()
if err != nil {
return err
}
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n%s",
from, strings.Join(cfg.To, ", "), mime.QEncoding.Encode("utf-8", subject),
time.Now().Format(time.RFC1123Z), strings.ReplaceAll(body, "\n", "\r\n"))
if _, err := w.Write([]byte(msg)); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}
// loginAuth implements the legacy AUTH LOGIN mechanism (Yandex, Mail.ru, Exchange).
type loginAuth struct{ user, pass string }
func (a loginAuth) Start(*smtp.ServerInfo) (string, []byte, error) { return "LOGIN", nil, nil }
func (a loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if !more {
return nil, nil
}
switch strings.ToLower(strings.TrimSpace(string(fromServer))) {
case "username:":
return []byte(a.user), nil
case "password:":
return []byte(a.pass), nil
}
return nil, fmt.Errorf("unexpected LOGIN challenge %q", fromServer)
}