rss email digests over ssh because you're a cool kid herald.dunkirk.sh
go rss rss-reader ssh charm
at main 5.4 kB view raw
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}