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.WithNotifySignal(os.Interrupt, os.Kill),
53 ); err != nil {
54 os.Exit(1)
55 }
56}
57
58func serveCmd() *cobra.Command {
59 return &cobra.Command{
60 Use: "serve",
61 Short: "Start the Herald server",
62 RunE: func(cmd *cobra.Command, args []string) error {
63 return runServer(cmd.Context())
64 },
65 }
66}
67
68func initCmd() *cobra.Command {
69 return &cobra.Command{
70 Use: "init [config_path]",
71 Short: "Generate a sample configuration file",
72 Long: "Create a config.yaml file with default values. If no path is provided, uses config.yaml",
73 Args: cobra.MaximumNArgs(1),
74 RunE: func(cmd *cobra.Command, args []string) error {
75 path := "config.yaml"
76 if len(args) > 0 {
77 path = args[0]
78 }
79
80 if _, err := os.Stat(path); err == nil {
81 return fmt.Errorf("config file already exists at %s", path)
82 }
83
84 sampleConfig := `# Herald Configuration
85
86host: 0.0.0.0
87ssh_port: 2222
88http_port: 8080
89
90# Public URL where Herald is accessible
91origin: http://localhost:8080
92
93# External SSH port (defaults to ssh_port if not set)
94# Use this when SSH is exposed through a different port publicly
95# external_ssh_port: 22
96
97# SSH host keys (generated on first run if missing)
98host_key_path: ./host_key
99
100# Database
101db_path: ./herald.db
102
103# SMTP
104smtp:
105 host: smtp.example.com
106 port: 587
107 user: sender@example.com
108 pass: ${SMTP_PASS} # Env var substitution
109 from: herald@example.com
110
111# Auth
112allow_all_keys: true
113# allowed_keys:
114# - "ssh-ed25519 AAAA... user@host"
115`
116
117 if err := os.WriteFile(path, []byte(sampleConfig), 0600); err != nil {
118 return fmt.Errorf("failed to write config file: %w", err)
119 }
120
121 logger.Info("created config file", "path", path)
122 return nil
123 },
124 }
125}
126
127func runServer(ctx context.Context) error {
128 cfg, err := config.LoadAppConfig(cfgFile)
129 if err != nil {
130 return fmt.Errorf("failed to load config: %w", err)
131 }
132
133 // Set log level from config
134 level := log.InfoLevel
135 switch strings.ToLower(cfg.LogLevel) {
136 case "debug":
137 level = log.DebugLevel
138 case "info":
139 level = log.InfoLevel
140 case "warn":
141 level = log.WarnLevel
142 case "error":
143 level = log.ErrorLevel
144 }
145 logger.SetLevel(level)
146
147 logger.Info("starting herald",
148 "ssh_port", cfg.SSHPort,
149 "http_port", cfg.HTTPPort,
150 "db_path", cfg.DBPath,
151 )
152
153 db, err := store.Open(cfg.DBPath)
154 if err != nil {
155 return fmt.Errorf("failed to open database: %w", err)
156 }
157 defer func() { _ = db.Close() }()
158
159 if err := db.Migrate(); err != nil {
160 return fmt.Errorf("failed to migrate database: %w", err)
161 }
162
163 mailer, err := email.NewMailer(email.SMTPConfig{
164 Host: cfg.SMTP.Host,
165 Port: cfg.SMTP.Port,
166 User: cfg.SMTP.User,
167 Pass: cfg.SMTP.Pass,
168 From: cfg.SMTP.From,
169 DKIMPrivateKey: cfg.SMTP.DKIMPrivateKey,
170 DKIMPrivateKeyFile: cfg.SMTP.DKIMPrivateKeyFile,
171 DKIMSelector: cfg.SMTP.DKIMSelector,
172 DKIMDomain: cfg.SMTP.DKIMDomain,
173 }, cfg.Origin)
174 if err != nil {
175 return fmt.Errorf("failed to create mailer: %w", err)
176 }
177
178 // Validate SMTP configuration
179 if err := mailer.ValidateConfig(); err != nil {
180 return fmt.Errorf("SMTP validation failed: %w", err)
181 }
182
183 sched := scheduler.NewScheduler(db, mailer, logger, 60*time.Second, cfg.Origin)
184
185 sshServer := ssh.NewServer(ssh.Config{
186 Host: cfg.Host,
187 Port: cfg.SSHPort,
188 HostKeyPath: cfg.HostKeyPath,
189 AllowAllKeys: cfg.AllowAllKeys,
190 AllowedKeys: cfg.AllowedKeys,
191 }, db, sched, logger)
192
193 // Get commit hash - prefer build-time embedded hash, fallback to git
194 hash := commitHash
195 if hash == "" || hash == "dev" {
196 cmd := exec.Command("git", "log", "-1", "--format=%H")
197 if output, err := cmd.Output(); err == nil {
198 hash = strings.TrimSpace(string(output))
199 } else {
200 hash = "dev"
201 }
202 }
203
204 webServer := web.NewServer(db, fmt.Sprintf("%s:%d", cfg.Host, cfg.HTTPPort), cfg.Origin, cfg.ExternalSSHPort, logger, hash)
205
206 g, ctx := errgroup.WithContext(ctx)
207
208 g.Go(func() error {
209 return sshServer.ListenAndServe(ctx)
210 })
211
212 g.Go(func() error {
213 return webServer.ListenAndServe(ctx)
214 })
215
216 g.Go(func() error {
217 defer func() {
218 if r := recover(); r != nil {
219 logger.Error("scheduler panic", "panic", r)
220 }
221 }()
222 sched.Start(ctx)
223 return nil
224 })
225
226 return g.Wait()
227}