+22
flake.lock
+22
flake.lock
···
561
561
"type": "github"
562
562
}
563
563
},
564
+
"herald": {
565
+
"inputs": {
566
+
"nixpkgs": [
567
+
"nixpkgs"
568
+
]
569
+
},
570
+
"locked": {
571
+
"lastModified": 1768060179,
572
+
"narHash": "sha256-tevqlXq0mrTo5KWLHQYjrgnhDvlTYblpEOjFDvnJg+c=",
573
+
"ref": "main",
574
+
"rev": "384c53a43b18d6e4351643055cedec76ec43b6c1",
575
+
"revCount": 44,
576
+
"type": "git",
577
+
"url": "https://tangled.org/dunkirk.sh/herald"
578
+
},
579
+
"original": {
580
+
"ref": "main",
581
+
"type": "git",
582
+
"url": "https://tangled.org/dunkirk.sh/herald"
583
+
}
584
+
},
564
585
"home-manager": {
565
586
"inputs": {
566
587
"nixpkgs": [
···
1100
1121
"flare": "flare",
1101
1122
"frc-nix": "frc-nix",
1102
1123
"hardware": "hardware",
1124
+
"herald": "herald",
1103
1125
"home-manager": "home-manager_2",
1104
1126
"hyprland-contrib": "hyprland-contrib",
1105
1127
"import-tree": "import-tree",
+6
flake.nix
+6
flake.nix
···
73
73
inputs.nixpkgs.follows = "nixpkgs";
74
74
};
75
75
76
+
herald = {
77
+
url = "git+https://tangled.org/dunkirk.sh/herald?ref=main";
78
+
inputs.nixpkgs.follows = "nixpkgs";
79
+
};
80
+
76
81
import-tree.url = "github:vic/import-tree";
77
82
78
83
nur = {
···
156
161
157
162
zmx-binary = prev.callPackage ./packages/zmx.nix { };
158
163
bore-auth = prev.callPackage ./packages/bore-auth.nix { };
164
+
herald = inputs.herald.packages.${prev.system}.default;
159
165
})
160
166
];
161
167
};
+5
machines/atalanta/home/default.nix
+5
machines/atalanta/home/default.nix
+31
machines/terebithia/default.nix
+31
machines/terebithia/default.nix
···
11
11
./home-manager.nix
12
12
13
13
(inputs.import-tree ../../modules/nixos)
14
+
../../modules/nixos/services/herald.nix
14
15
inputs.tangled.nixosModules.knot
15
16
inputs.tangled.nixosModules.spindle
16
17
];
···
142
143
file = ../../secrets/control.age;
143
144
owner = "control";
144
145
};
146
+
herald = {
147
+
file = ../../secrets/herald.age;
148
+
owner = "herald";
149
+
};
150
+
herald-dkim = {
151
+
file = ../../secrets/herald-dkim.age;
152
+
owner = "herald";
153
+
mode = "0400";
154
+
};
145
155
146
156
"restic/env".file = ../../secrets/restic/env.age;
147
157
"restic/repo".file = ../../secrets/restic/repo.age;
···
223
233
22
224
234
80
225
235
443
236
+
2223 # Herald SSH
226
237
28868 # Minecraft server
227
238
];
228
239
allowedUDPPorts = [
···
483
494
};
484
495
};
485
496
};
497
+
};
498
+
499
+
atelier.services.herald = {
500
+
enable = true;
501
+
domain = "herald.dunkirk.sh";
502
+
sshPort = 2223;
503
+
externalSshPort = 2223;
504
+
httpPort = 8085;
505
+
smtp = {
506
+
host = "smtp.mailchannels.net";
507
+
port = 587;
508
+
user = "kieranklukascontracting";
509
+
from = "herald@dunkirk.sh";
510
+
dkim = {
511
+
selector = "mailchannels";
512
+
domain = "dunkirk.sh";
513
+
privateKeyFile = "${config.age.secrets.herald-dkim.path}";
514
+
};
515
+
};
516
+
secretsFile = config.age.secrets.herald.path;
486
517
};
487
518
488
519
services.n8n = {
+219
modules/nixos/services/herald.nix
+219
modules/nixos/services/herald.nix
···
1
+
# Herald - RSS-to-Email via SSH
2
+
#
3
+
# Feeds uploaded via SSH/SCP, emails sent on schedule
4
+
5
+
{ config, lib, pkgs, ... }:
6
+
7
+
let
8
+
cfg = config.atelier.services.herald;
9
+
10
+
# Generate config.yaml from options
11
+
configFile = pkgs.writeText "herald-config.yaml" ''
12
+
host: ${cfg.host}
13
+
ssh_port: ${toString cfg.sshPort}
14
+
http_port: ${toString cfg.httpPort}
15
+
origin: https://${cfg.domain}
16
+
external_ssh_port: ${toString cfg.externalSshPort}
17
+
18
+
host_key_path: ${cfg.dataDir}/host_key
19
+
db_path: ${cfg.dataDir}/herald.db
20
+
21
+
smtp:
22
+
host: ${cfg.smtp.host}
23
+
port: ${toString cfg.smtp.port}
24
+
user: ${cfg.smtp.user}
25
+
pass: ''${SMTP_PASS}
26
+
from: ${cfg.smtp.from}
27
+
${lib.optionalString (cfg.smtp.dkim.selector != null) ''dkim_selector: ${cfg.smtp.dkim.selector}''}
28
+
${lib.optionalString (cfg.smtp.dkim.domain != null) ''dkim_domain: ${cfg.smtp.dkim.domain}''}
29
+
${lib.optionalString (cfg.smtp.dkim.privateKeyFile != null) ''dkim_private_key_file: ${cfg.smtp.dkim.privateKeyFile}''}
30
+
31
+
allow_all_keys: ${if cfg.allowAllKeys then "true" else "false"}
32
+
'';
33
+
in
34
+
{
35
+
options.atelier.services.herald = {
36
+
enable = lib.mkEnableOption "Herald RSS-to-Email service";
37
+
38
+
domain = lib.mkOption {
39
+
type = lib.types.str;
40
+
description = "Domain to serve Herald on";
41
+
example = "herald.dunkirk.sh";
42
+
};
43
+
44
+
host = lib.mkOption {
45
+
type = lib.types.str;
46
+
default = "0.0.0.0";
47
+
description = "Host address to bind to";
48
+
};
49
+
50
+
sshPort = lib.mkOption {
51
+
type = lib.types.port;
52
+
default = 2223;
53
+
description = "Internal SSH port for Herald";
54
+
};
55
+
56
+
externalSshPort = lib.mkOption {
57
+
type = lib.types.port;
58
+
default = 2223;
59
+
description = "External SSH port (for display in UI)";
60
+
};
61
+
62
+
httpPort = lib.mkOption {
63
+
type = lib.types.port;
64
+
default = 8085;
65
+
description = "Internal HTTP port for Herald web interface";
66
+
};
67
+
68
+
dataDir = lib.mkOption {
69
+
type = lib.types.path;
70
+
default = "/var/lib/herald";
71
+
description = "Directory to store Herald data";
72
+
};
73
+
74
+
allowAllKeys = lib.mkOption {
75
+
type = lib.types.bool;
76
+
default = true;
77
+
description = "Allow all SSH keys (false to use allowed_keys)";
78
+
};
79
+
80
+
smtp = {
81
+
host = lib.mkOption {
82
+
type = lib.types.str;
83
+
description = "SMTP server host";
84
+
example = "smtp.gmail.com";
85
+
};
86
+
87
+
port = lib.mkOption {
88
+
type = lib.types.port;
89
+
default = 587;
90
+
description = "SMTP server port";
91
+
};
92
+
93
+
user = lib.mkOption {
94
+
type = lib.types.str;
95
+
description = "SMTP username";
96
+
};
97
+
98
+
from = lib.mkOption {
99
+
type = lib.types.str;
100
+
description = "From address for emails";
101
+
example = "herald@dunkirk.sh";
102
+
};
103
+
104
+
dkim = {
105
+
selector = lib.mkOption {
106
+
type = lib.types.nullOr lib.types.str;
107
+
default = null;
108
+
description = "DKIM selector";
109
+
example = "mailchannels";
110
+
};
111
+
112
+
domain = lib.mkOption {
113
+
type = lib.types.nullOr lib.types.str;
114
+
default = null;
115
+
description = "DKIM domain";
116
+
example = "dunkirk.sh";
117
+
};
118
+
119
+
privateKeyFile = lib.mkOption {
120
+
type = lib.types.nullOr lib.types.path;
121
+
default = null;
122
+
description = "Path to DKIM private key file";
123
+
example = "/var/lib/herald/dkim_private.pem";
124
+
};
125
+
};
126
+
};
127
+
128
+
secretsFile = lib.mkOption {
129
+
type = lib.types.path;
130
+
description = "Path to agenix secrets file (must contain SMTP_PASS)";
131
+
};
132
+
133
+
package = lib.mkOption {
134
+
type = lib.types.package;
135
+
default = pkgs.herald;
136
+
description = "Herald package to use";
137
+
};
138
+
};
139
+
140
+
config = lib.mkIf cfg.enable {
141
+
# Create user and group
142
+
users.groups.services = {};
143
+
144
+
users.users.herald = {
145
+
isSystemUser = true;
146
+
group = "herald";
147
+
extraGroups = [ "services" ];
148
+
home = cfg.dataDir;
149
+
createHome = true;
150
+
shell = pkgs.bash;
151
+
};
152
+
153
+
users.groups.herald = {};
154
+
155
+
# Systemd service
156
+
systemd.services.herald = {
157
+
description = "Herald RSS-to-Email service";
158
+
wantedBy = [ "multi-user.target" ];
159
+
after = [ "network.target" ];
160
+
161
+
serviceConfig = {
162
+
Type = "simple";
163
+
User = "herald";
164
+
Group = "herald";
165
+
WorkingDirectory = cfg.dataDir;
166
+
EnvironmentFile = cfg.secretsFile;
167
+
ExecStart = "${cfg.package}/bin/herald serve -c ${configFile}";
168
+
Restart = "always";
169
+
RestartSec = "10s";
170
+
171
+
# Security hardening
172
+
NoNewPrivileges = true;
173
+
ProtectSystem = "strict";
174
+
ProtectHome = true;
175
+
ReadWritePaths = [ cfg.dataDir ];
176
+
PrivateTmp = true;
177
+
};
178
+
179
+
preStart = ''
180
+
mkdir -p ${cfg.dataDir}
181
+
chown -R herald:services ${cfg.dataDir}
182
+
chmod -R g+rwX ${cfg.dataDir}
183
+
'';
184
+
};
185
+
186
+
# Ensure working directory exists
187
+
systemd.tmpfiles.rules = [
188
+
"d ${cfg.dataDir} 0755 herald services -"
189
+
];
190
+
191
+
# Open firewall ports
192
+
networking.firewall.allowedTCPPorts = [ cfg.sshPort ];
193
+
194
+
# Caddy reverse proxy for HTTP interface
195
+
services.caddy.virtualHosts.${cfg.domain} = {
196
+
extraConfig = ''
197
+
tls {
198
+
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
199
+
}
200
+
header {
201
+
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
202
+
}
203
+
reverse_proxy localhost:${toString cfg.httpPort} {
204
+
header_up X-Forwarded-Proto {scheme}
205
+
header_up X-Forwarded-For {remote}
206
+
}
207
+
'';
208
+
};
209
+
210
+
# Backup configuration
211
+
atelier.backup.services.herald = {
212
+
paths = [ cfg.dataDir ];
213
+
exclude = [ "*.log" ];
214
+
# Uses SQLite, stop before backup
215
+
preBackup = "systemctl stop herald";
216
+
postBackup = "systemctl start herald";
217
+
};
218
+
};
219
+
}
secrets/herald-dkim.age
secrets/herald-dkim.age
This is a binary file and will not be displayed.
+13
secrets/herald.age
+13
secrets/herald.age
···
1
+
age-encryption.org/v1
2
+
-> ssh-rsa DqcG0Q
3
+
FM0Rezreqi8ZVC6v8KBCGxzdWmHBm7KRYK9RiS9LiWRsMjhhmgGZUM7lIjafKtJy
4
+
9TXHSQeXAv6iA7W5TZ059EJTx3R5q3Dn8Dim3MtLTUtthSSPst+QO9eWxBxnWmxo
5
+
ZhzcmWO1Li6qmp8Mk6vO+lAdOrPWM91gPjQnubXBhzomXPMzlTlLYaSxSn3eMk+6
6
+
uwMD4XSxKIdcXTjGPzSs+NnHEo6fw6WOCU1W0k+Ex7Aajj0qBXN+j86XIhloODv+
7
+
bAZ/g9ozxOTAiTZyVv2/mXGpOqUcfDn/T8Wx54EIdYdr1lbBO1lTOZ+oHoQF9HhI
8
+
dJawI3lPyjrmnREpNa3nCjzZlbuunj0cWn2vwYBEju6wcYoB6t840iQl4CY+OJ0Y
9
+
zAYy/JaEOmvx6qBVrDoPZQOZfErwDzqUzQOXkf8/D7e2sWtUDeN1TxcQZzRoV6Zj
10
+
cYcGSQlbygLDWwJgIeuzCbgnYnKFkrnDHN5C5b/dHrJ30ozPd4skqf8dHj8JZ+VJ
11
+
12
+
--- MYSw03+0Llf1UwKqYqEhkoKNNnfKJcHN6jsyy0PNAVc
13
+
D����]EI��Y�f�5��]ː����N�H��l������i���g�`
P\'�3��K"[���]Ga