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

Configure Feed

Select the types of activity you want to include in your feed.

at v0.1.1 227 lines 5.3 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.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}