···4455The canonical repo for this is hosted on tangled over at [`dunkirk.sh/herald`](https://tangled.org/@dunkirk.sh/herald)
6677+## Quick Start
88+99+```bash
1010+# Build
1111+go build -o herald .
1212+1313+# Run the server
1414+./herald serve
1515+1616+# Or with a config file
1717+./herald serve -c config.yaml
1818+```
1919+2020+## Usage
2121+2222+### Upload a config
2323+2424+Create a `feeds.txt` file:
2525+2626+```text
2727+=: email you@example.com
2828+=: cron 0 8 * * *
2929+=: digest true
3030+=> https://blog.example.com/rss
3131+=> https://news.ycombinator.com/rss
3232+=> https://lobste.rs/rss "Lobsters"
3333+```
3434+3535+Upload via SCP:
3636+3737+```bash
3838+scp feeds.txt user@herald.example.com:
3939+```
4040+4141+### SSH Commands
4242+4343+```bash
4444+# List your configs
4545+ssh herald.example.com ls
4646+4747+# Show config contents
4848+ssh herald.example.com cat feeds.txt
4949+5050+# Delete a config
5151+ssh herald.example.com rm feeds.txt
5252+5353+# Run immediately (don't wait for cron)
5454+ssh herald.example.com run feeds.txt
5555+5656+# Show recent activity
5757+ssh herald.example.com logs
5858+```
5959+6060+### Web Interface
6161+6262+Visit `http://localhost:8080` for the landing page, or `http://localhost:8080/{fingerprint}` for your user page with aggregated RSS/JSON feeds.
6363+6464+## Config Format
6565+6666+### Directives
6767+6868+| Directive | Required | Description |
6969+| ------------------- | -------- | ------------------------------------------------ |
7070+| `=: email <addr>` | Yes | Recipient email address |
7171+| `=: cron <expr>` | Yes | Standard cron expression (5 fields) |
7272+| `=: digest <bool>` | No | Combine all items into one email (default: true) |
7373+| `=: inline <bool>` | No | Include article content in email (default: true) |
7474+| `=> <url> ["name"]` | Yes (1+) | RSS/Atom feed URL, optional display name |
7575+7676+## Configuration
7777+7878+Create a `config.yaml`:
7979+8080+```yaml
8181+host: 0.0.0.0
8282+ssh_port: 2222
8383+http_port: 8080
8484+8585+host_key_path: ./host_key
8686+db_path: ./herald.db
8787+8888+smtp:
8989+ host: smtp.example.com
9090+ port: 587
9191+ user: sender@example.com
9292+ pass: ${SMTP_PASS}
9393+ from: herald@example.com
9494+9595+allow_all_keys: true
9696+```
9797+9898+Environment variables can also be used:
9999+100100+- `HERALD_HOST`
101101+- `HERALD_SSH_PORT`
102102+- `HERALD_HTTP_PORT`
103103+- `HERALD_DB_PATH`
104104+- `HERALD_SMTP_HOST`
105105+- `HERALD_SMTP_PORT`
106106+- `HERALD_SMTP_USER`
107107+- `HERALD_SMTP_PASS`
108108+- `HERALD_SMTP_FROM`
109109+7110<p align="center">
8111 <img src="https://raw.githubusercontent.com/taciturnaxolotl/carriage/main/.github/images/line-break.svg" />
9112</p>
+31
config.example.yaml
···11+# Herald Configuration
22+33+host: 0.0.0.0
44+ssh_port: 2222
55+http_port: 8080
66+77+# Public URL where Herald is accessible
88+origin: http://localhost:8080
99+1010+# External SSH port (defaults to ssh_port if not set)
1111+# Use this when SSH is exposed through a different port publicly
1212+# external_ssh_port: 22
1313+1414+# SSH host keys (generated on first run if missing)
1515+host_key_path: ./host_key
1616+1717+# Database
1818+db_path: ./herald.db
1919+2020+# SMTP
2121+smtp:
2222+ host: smtp.example.com
2323+ port: 587
2424+ user: sender@example.com
2525+ pass: ${SMTP_PASS} # Env var substitution
2626+ from: herald@example.com
2727+2828+# Auth
2929+allow_all_keys: true
3030+# allowed_keys:
3131+# - "ssh-ed25519 AAAA... user@host"
+152
config/app.go
···11+package config
22+33+import (
44+ "fmt"
55+ "os"
66+ "path/filepath"
77+ "strconv"
88+ "strings"
99+1010+ "github.com/joho/godotenv"
1111+ "gopkg.in/yaml.v3"
1212+)
1313+1414+type AppConfig struct {
1515+ Host string `yaml:"host"`
1616+ SSHPort int `yaml:"ssh_port"`
1717+ ExternalSSHPort int `yaml:"external_ssh_port"`
1818+ HTTPPort int `yaml:"http_port"`
1919+ HostKeyPath string `yaml:"host_key_path"`
2020+ DBPath string `yaml:"db_path"`
2121+ Origin string `yaml:"origin"`
2222+ SMTP SMTPConfig `yaml:"smtp"`
2323+ AllowAllKeys bool `yaml:"allow_all_keys"`
2424+ AllowedKeys []string `yaml:"allowed_keys"`
2525+}
2626+2727+type SMTPConfig struct {
2828+ Host string `yaml:"host"`
2929+ Port int `yaml:"port"`
3030+ User string `yaml:"user"`
3131+ Pass string `yaml:"pass"`
3232+ From string `yaml:"from"`
3333+}
3434+3535+func DefaultAppConfig() *AppConfig {
3636+ return &AppConfig{
3737+ Host: "0.0.0.0",
3838+ SSHPort: 2222,
3939+ HTTPPort: 8080,
4040+ HostKeyPath: "./host_key",
4141+ DBPath: "./herald.db",
4242+ Origin: "http://localhost:8080",
4343+ SMTP: SMTPConfig{
4444+ Host: "localhost",
4545+ Port: 587,
4646+ From: "herald@localhost",
4747+ },
4848+ AllowAllKeys: true,
4949+ }
5050+}
5151+5252+func LoadAppConfig(path string) (*AppConfig, error) {
5353+ cfg := DefaultAppConfig()
5454+5555+ // Load .env file if it exists (silently ignore if not found)
5656+ if envPath := findEnvFile(path); envPath != "" {
5757+ _ = godotenv.Load(envPath)
5858+ }
5959+6060+ if path != "" {
6161+ data, err := os.ReadFile(path)
6262+ if err != nil {
6363+ return nil, fmt.Errorf("failed to read config file: %w", err)
6464+ }
6565+6666+ expanded := os.Expand(string(data), func(key string) string {
6767+ return os.Getenv(key)
6868+ })
6969+7070+ if err := yaml.Unmarshal([]byte(expanded), cfg); err != nil {
7171+ return nil, fmt.Errorf("failed to parse config file: %w", err)
7272+ }
7373+ }
7474+7575+ applyEnvOverrides(cfg)
7676+7777+ // Default external_ssh_port to ssh_port if not set
7878+ if cfg.ExternalSSHPort == 0 {
7979+ cfg.ExternalSSHPort = cfg.SSHPort
8080+ }
8181+8282+ return cfg, nil
8383+}
8484+8585+// findEnvFile looks for .env file in the config file's directory or current directory
8686+func findEnvFile(configPath string) string {
8787+ // If config path provided, look in its directory
8888+ if configPath != "" {
8989+ dir := filepath.Dir(configPath)
9090+ envPath := filepath.Join(dir, ".env")
9191+ if _, err := os.Stat(envPath); err == nil {
9292+ return envPath
9393+ }
9494+ }
9595+9696+ // Otherwise check current directory
9797+ if _, err := os.Stat(".env"); err == nil {
9898+ return ".env"
9999+ }
100100+101101+ return ""
102102+}
103103+104104+func applyEnvOverrides(cfg *AppConfig) {
105105+ if v := os.Getenv("HERALD_HOST"); v != "" {
106106+ cfg.Host = v
107107+ }
108108+ if v := os.Getenv("HERALD_SSH_PORT"); v != "" {
109109+ if port, err := strconv.Atoi(v); err == nil {
110110+ cfg.SSHPort = port
111111+ }
112112+ }
113113+ if v := os.Getenv("HERALD_EXTERNAL_SSH_PORT"); v != "" {
114114+ if port, err := strconv.Atoi(v); err == nil {
115115+ cfg.ExternalSSHPort = port
116116+ }
117117+ }
118118+ if v := os.Getenv("HERALD_HTTP_PORT"); v != "" {
119119+ if port, err := strconv.Atoi(v); err == nil {
120120+ cfg.HTTPPort = port
121121+ }
122122+ }
123123+ if v := os.Getenv("HERALD_HOST_KEY_PATH"); v != "" {
124124+ cfg.HostKeyPath = v
125125+ }
126126+ if v := os.Getenv("HERALD_DB_PATH"); v != "" {
127127+ cfg.DBPath = v
128128+ }
129129+ if v := os.Getenv("HERALD_SMTP_HOST"); v != "" {
130130+ cfg.SMTP.Host = v
131131+ }
132132+ if v := os.Getenv("HERALD_SMTP_PORT"); v != "" {
133133+ if port, err := strconv.Atoi(v); err == nil {
134134+ cfg.SMTP.Port = port
135135+ }
136136+ }
137137+ if v := os.Getenv("HERALD_SMTP_USER"); v != "" {
138138+ cfg.SMTP.User = v
139139+ }
140140+ if v := os.Getenv("HERALD_SMTP_PASS"); v != "" {
141141+ cfg.SMTP.Pass = v
142142+ }
143143+ if v := os.Getenv("HERALD_SMTP_FROM"); v != "" {
144144+ cfg.SMTP.From = v
145145+ }
146146+ if v := os.Getenv("HERALD_ALLOW_ALL_KEYS"); v != "" {
147147+ cfg.AllowAllKeys = strings.ToLower(v) == "true"
148148+ }
149149+ if v := os.Getenv("HERALD_ORIGIN"); v != "" {
150150+ cfg.Origin = v
151151+ }
152152+}