rss email digests over ssh because you're a cool kid herald.dunkirk.sh
go rss rss-reader ssh charm

feat: add input validation

dunkirk.sh df84190d faeb5870

verified
Changed files
+122 -3
config
email
ssh
+1 -1
.gitignore
··· 5 5 config.yaml 6 6 7 7 # Database 8 - *.db 8 + *.db* 9 9 10 10 # SSH host keys 11 11 host_key
+43
config/validate.go
··· 1 1 package config 2 2 3 3 import ( 4 + "context" 4 5 "errors" 6 + "fmt" 7 + "net/http" 5 8 "net/mail" 6 9 "net/url" 10 + "time" 7 11 8 12 "github.com/adhocore/gronx" 13 + "github.com/mmcdole/gofeed" 9 14 ) 10 15 11 16 var ( ··· 46 51 47 52 return nil 48 53 } 54 + 55 + // ValidateFeedURLs attempts to fetch and parse each feed URL with a short timeout 56 + func ValidateFeedURLs(ctx context.Context, cfg *ParsedConfig) error { 57 + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 58 + defer cancel() 59 + 60 + parser := gofeed.NewParser() 61 + client := &http.Client{ 62 + Timeout: 5 * time.Second, 63 + } 64 + 65 + for _, feed := range cfg.Feeds { 66 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, feed.URL, nil) 67 + if err != nil { 68 + return fmt.Errorf("invalid feed URL %s: %w", feed.URL, err) 69 + } 70 + 71 + req.Header.Set("User-Agent", "Herald/1.0 (RSS Aggregator)") 72 + 73 + resp, err := client.Do(req) 74 + if err != nil { 75 + return fmt.Errorf("failed to fetch feed %s: %w", feed.URL, err) 76 + } 77 + 78 + if resp.StatusCode != http.StatusOK { 79 + resp.Body.Close() 80 + return fmt.Errorf("feed %s returned status %d", feed.URL, resp.StatusCode) 81 + } 82 + 83 + _, err = parser.Parse(resp.Body) 84 + resp.Body.Close() 85 + if err != nil { 86 + return fmt.Errorf("failed to parse feed %s: %w", feed.URL, err) 87 + } 88 + } 89 + 90 + return nil 91 + }
+66
email/send.go
··· 30 30 } 31 31 } 32 32 33 + // ValidateConfig tests SMTP connectivity and auth 34 + func (m *Mailer) ValidateConfig() error { 35 + addr := net.JoinHostPort(m.cfg.Host, fmt.Sprintf("%d", m.cfg.Port)) 36 + 37 + var auth smtp.Auth 38 + if m.cfg.User != "" && m.cfg.Pass != "" { 39 + auth = smtp.PlainAuth("", m.cfg.User, m.cfg.Pass, m.cfg.Host) 40 + } 41 + 42 + // Port 465 uses implicit TLS 43 + if m.cfg.Port == 465 { 44 + tlsConfig := &tls.Config{ 45 + ServerName: m.cfg.Host, 46 + } 47 + 48 + conn, err := tls.Dial("tcp", addr, tlsConfig) 49 + if err != nil { 50 + return fmt.Errorf("TLS dial: %w", err) 51 + } 52 + defer conn.Close() 53 + 54 + client, err := smtp.NewClient(conn, m.cfg.Host) 55 + if err != nil { 56 + return fmt.Errorf("SMTP client: %w", err) 57 + } 58 + defer client.Close() 59 + 60 + if auth != nil { 61 + if err = client.Auth(auth); err != nil { 62 + return fmt.Errorf("auth: %w", err) 63 + } 64 + } 65 + 66 + return client.Quit() 67 + } 68 + 69 + // Port 587 uses STARTTLS 70 + conn, err := net.Dial("tcp", addr) 71 + if err != nil { 72 + return fmt.Errorf("dial: %w", err) 73 + } 74 + defer conn.Close() 75 + 76 + client, err := smtp.NewClient(conn, m.cfg.Host) 77 + if err != nil { 78 + return fmt.Errorf("SMTP client: %w", err) 79 + } 80 + defer client.Close() 81 + 82 + // Start TLS before auth 83 + tlsConfig := &tls.Config{ 84 + ServerName: m.cfg.Host, 85 + } 86 + if err = client.StartTLS(tlsConfig); err != nil { 87 + return fmt.Errorf("STARTTLS: %w", err) 88 + } 89 + 90 + if auth != nil { 91 + if err = client.Auth(auth); err != nil { 92 + return fmt.Errorf("auth: %w", err) 93 + } 94 + } 95 + 96 + return client.Quit() 97 + } 98 + 33 99 func (m *Mailer) Send(to, subject, htmlBody, textBody, unsubToken, dashboardURL string) error { 34 100 addr := net.JoinHostPort(m.cfg.Host, fmt.Sprintf("%d", m.cfg.Port)) 35 101
+5
main.go
··· 169 169 From: cfg.SMTP.From, 170 170 }, cfg.Origin) 171 171 172 + // Validate SMTP configuration 173 + if err := mailer.ValidateConfig(); err != nil { 174 + return fmt.Errorf("SMTP validation failed: %w", err) 175 + } 176 + 172 177 sched := scheduler.NewScheduler(db, mailer, logger, 60*time.Second, cfg.Origin) 173 178 174 179 sshServer := ssh.NewServer(ssh.Config{
+7 -2
ssh/scp.go
··· 137 137 return 0, fmt.Errorf("invalid config: %w", err) 138 138 } 139 139 140 + ctx := s.Context() 141 + 142 + // Validate feed URLs by attempting to fetch them 143 + if err := config.ValidateFeedURLs(ctx, parsed); err != nil { 144 + return 0, fmt.Errorf("feed validation failed: %w", err) 145 + } 146 + 140 147 nextRun, err := calculateNextRun(parsed.CronExpr) 141 148 if err != nil { 142 149 return 0, fmt.Errorf("failed to calculate next run: %w", err) 143 150 } 144 - 145 - ctx := s.Context() 146 151 147 152 // Use transaction for config update 148 153 tx, err := h.store.BeginTx(ctx)