rss email digests over ssh because you're a cool kid
herald.dunkirk.sh
go
rss
rss-reader
ssh
charm
1package main
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "os/exec"
8 "strings"
9 "time"
10
11 "github.com/charmbracelet/fang"
12 "github.com/charmbracelet/log"
13 "github.com/kierank/herald/config"
14 "github.com/kierank/herald/email"
15 "github.com/kierank/herald/scheduler"
16 "github.com/kierank/herald/ssh"
17 "github.com/kierank/herald/store"
18 "github.com/kierank/herald/web"
19 "github.com/spf13/cobra"
20 "golang.org/x/sync/errgroup"
21)
22
23var (
24 version = "dev"
25 commitHash = "dev"
26 cfgFile string
27 logger *log.Logger
28)
29
30func main() {
31 logger = log.NewWithOptions(os.Stderr, log.Options{
32 ReportTimestamp: true,
33 Level: log.InfoLevel,
34 })
35
36 rootCmd := &cobra.Command{
37 Use: "herald",
38 Short: "RSS-to-Email via SSH",
39 Long: `Herald is a minimal, SSH-powered RSS to email service.
40Upload a feed config via SCP, get email digests on a schedule.`,
41 Version: version,
42 }
43
44 rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path")
45
46 rootCmd.AddCommand(serveCmd())
47 rootCmd.AddCommand(initCmd())
48
49 if err := fang.Execute(
50 context.Background(),
51 rootCmd,
52 fang.WithVersion(version),
53 fang.WithCommit(commitHash),
54 fang.WithNotifySignal(os.Interrupt, os.Kill),
55 ); err != nil {
56 os.Exit(1)
57 }
58}
59
60func serveCmd() *cobra.Command {
61 return &cobra.Command{
62 Use: "serve",
63 Short: "Start the Herald server",
64 RunE: func(cmd *cobra.Command, args []string) error {
65 return runServer(cmd.Context())
66 },
67 }
68}
69
70func initCmd() *cobra.Command {
71 return &cobra.Command{
72 Use: "init [config_path]",
73 Short: "Generate a sample configuration file",
74 Long: "Create a config.yaml file with default values. If no path is provided, uses config.yaml",
75 Args: cobra.MaximumNArgs(1),
76 RunE: func(cmd *cobra.Command, args []string) error {
77 path := "config.yaml"
78 if len(args) > 0 {
79 path = args[0]
80 }
81
82 if _, err := os.Stat(path); err == nil {
83 return fmt.Errorf("config file already exists at %s", path)
84 }
85
86 sampleConfig := `# Herald Configuration
87
88host: 0.0.0.0
89ssh_port: 2222
90http_port: 8080
91
92# Public URL where Herald is accessible
93origin: http://localhost:8080
94
95# External SSH port (defaults to ssh_port if not set)
96# Use this when SSH is exposed through a different port publicly
97# external_ssh_port: 22
98
99# SSH host keys (generated on first run if missing)
100host_key_path: ./host_key
101
102# Database
103db_path: ./herald.db
104
105# SMTP
106smtp:
107 host: smtp.example.com
108 port: 587
109 user: sender@example.com
110 pass: ${SMTP_PASS} # Env var substitution
111 from: herald@example.com
112
113# Auth
114allow_all_keys: true
115# allowed_keys:
116# - "ssh-ed25519 AAAA... user@host"
117`
118
119 if err := os.WriteFile(path, []byte(sampleConfig), 0600); err != nil {
120 return fmt.Errorf("failed to write config file: %w", err)
121 }
122
123 logger.Info("created config file", "path", path)
124 return nil
125 },
126 }
127}
128
129func runServer(ctx context.Context) error {
130 cfg, err := config.LoadAppConfig(cfgFile)
131 if err != nil {
132 return fmt.Errorf("failed to load config: %w", err)
133 }
134
135 // Set log level from config
136 level := log.InfoLevel
137 switch strings.ToLower(cfg.LogLevel) {
138 case "debug":
139 level = log.DebugLevel
140 case "info":
141 level = log.InfoLevel
142 case "warn":
143 level = log.WarnLevel
144 case "error":
145 level = log.ErrorLevel
146 }
147 logger.SetLevel(level)
148
149 logger.Info("starting herald",
150 "ssh_port", cfg.SSHPort,
151 "http_port", cfg.HTTPPort,
152 "db_path", cfg.DBPath,
153 )
154
155 db, err := store.Open(cfg.DBPath)
156 if err != nil {
157 return fmt.Errorf("failed to open database: %w", err)
158 }
159 defer func() { _ = db.Close() }()
160
161 if err := db.Migrate(); err != nil {
162 return fmt.Errorf("failed to migrate database: %w", err)
163 }
164
165 mailer, err := email.NewMailer(email.SMTPConfig{
166 Host: cfg.SMTP.Host,
167 Port: cfg.SMTP.Port,
168 User: cfg.SMTP.User,
169 Pass: cfg.SMTP.Pass,
170 From: cfg.SMTP.From,
171 DKIMPrivateKey: cfg.SMTP.DKIMPrivateKey,
172 DKIMPrivateKeyFile: cfg.SMTP.DKIMPrivateKeyFile,
173 DKIMSelector: cfg.SMTP.DKIMSelector,
174 DKIMDomain: cfg.SMTP.DKIMDomain,
175 }, cfg.Origin)
176 if err != nil {
177 return fmt.Errorf("failed to create mailer: %w", err)
178 }
179
180 // Validate SMTP configuration
181 if err := mailer.ValidateConfig(); err != nil {
182 return fmt.Errorf("SMTP validation failed: %w", err)
183 }
184
185 sched := scheduler.NewScheduler(db, mailer, logger, 60*time.Second, cfg.Origin)
186
187 sshServer := ssh.NewServer(ssh.Config{
188 Host: cfg.Host,
189 Port: cfg.SSHPort,
190 HostKeyPath: cfg.HostKeyPath,
191 AllowAllKeys: cfg.AllowAllKeys,
192 AllowedKeys: cfg.AllowedKeys,
193 }, db, sched, logger)
194
195 // Get commit hash - prefer build-time embedded hash, fallback to git
196 hash := commitHash
197 if hash == "" || hash == "dev" {
198 cmd := exec.Command("git", "log", "-1", "--format=%H")
199 if output, err := cmd.Output(); err == nil {
200 hash = strings.TrimSpace(string(output))
201 } else {
202 hash = "dev"
203 }
204 }
205
206 webServer := web.NewServer(db, fmt.Sprintf("%s:%d", cfg.Host, cfg.HTTPPort), cfg.Origin, cfg.ExternalSSHPort, logger, hash)
207
208 g, ctx := errgroup.WithContext(ctx)
209
210 g.Go(func() error {
211 return sshServer.ListenAndServe(ctx)
212 })
213
214 g.Go(func() error {
215 return webServer.ListenAndServe(ctx)
216 })
217
218 g.Go(func() error {
219 defer func() {
220 if r := recover(); r != nil {
221 logger.Error("scheduler panic", "panic", r)
222 }
223 }()
224 sched.Start(ctx)
225 return nil
226 })
227
228 return g.Wait()
229}