Coffee journaling on ATProto (alpha)
alpha.arabica.social
coffee
1{ config, lib, pkgs, ... }:
2
3let
4 cfg = config.services.arabica;
5
6 moderatorUserType = lib.types.submodule {
7 options = {
8 did = lib.mkOption {
9 type = lib.types.str;
10 description = "AT Protocol DID of the moderator.";
11 example = "did:plc:abc123xyz";
12 };
13 handle = lib.mkOption {
14 type = lib.types.str;
15 default = "";
16 description = "Optional handle for the moderator (for readability).";
17 example = "alice.bsky.social";
18 };
19 role = lib.mkOption {
20 type = lib.types.enum [ "admin" "moderator" ];
21 description = "The moderation role assigned to this user.";
22 };
23 note = lib.mkOption {
24 type = lib.types.str;
25 default = "";
26 description = "Optional note about this moderator.";
27 };
28 };
29 };
30
31 # Build the moderators JSON config file from Nix settings
32 moderatorsConfigFile = pkgs.writeText "moderators.json" (builtins.toJSON {
33 roles = {
34 admin = {
35 description = "Full platform control";
36 permissions = [
37 "hide_record"
38 "unhide_record"
39 "blacklist_user"
40 "unblacklist_user"
41 "view_reports"
42 "dismiss_report"
43 "view_audit_log"
44 "reset_autohide"
45 ];
46 };
47 moderator = {
48 description = "Content moderation";
49 permissions =
50 [ "hide_record" "unhide_record" "view_reports" "dismiss_report" ];
51 };
52 };
53 users = map (u:
54 {
55 inherit (u) did role;
56 } // lib.optionalAttrs (u.handle != "") { inherit (u) handle; }
57 // lib.optionalAttrs (u.note != "") { inherit (u) note; })
58 cfg.moderation.moderators;
59 });
60
61 # Resolve the config path: explicit file takes priority, then generated from moderators list
62 effectiveConfigPath = if cfg.moderation.configFile != null then
63 cfg.moderation.configFile
64 else if cfg.moderation.moderators != [ ] then
65 moderatorsConfigFile
66 else
67 null;
68in {
69 options.services.arabica = {
70 enable = lib.mkEnableOption "Arabica coffee brew tracking service";
71
72 package = lib.mkOption {
73 type = lib.types.package;
74 default = pkgs.callPackage ./default.nix { };
75 defaultText = lib.literalExpression "pkgs.callPackage ./default.nix { }";
76 description = "The arabica package to use.";
77 };
78
79 settings = {
80 port = lib.mkOption {
81 type = lib.types.port;
82 default = 18910;
83 description = "Port on which the arabica server listens.";
84 };
85
86 logLevel = lib.mkOption {
87 type = lib.types.enum [ "debug" "info" "warn" "error" ];
88 default = "info";
89 description = "Log level for the arabica server.";
90 };
91
92 logFormat = lib.mkOption {
93 type = lib.types.enum [ "pretty" "json" ];
94 default = "json";
95 description =
96 "Log format. Use 'json' for production, 'pretty' for development.";
97 };
98
99 secureCookies = lib.mkOption {
100 type = lib.types.bool;
101 default = true;
102 description =
103 "Whether to set the Secure flag on cookies. Should be true when using HTTPS.";
104 };
105 };
106
107 moderation = {
108 configFile = lib.mkOption {
109 type = lib.types.nullOr lib.types.path;
110 default = null;
111 description = ''
112 Path to a moderators JSON config file. If set, this takes priority
113 over the `moderators` list option. See the project README for the
114 expected format.
115 '';
116 example = "/etc/arabica/moderators.json";
117 };
118
119 moderators = lib.mkOption {
120 type = lib.types.listOf moderatorUserType;
121 default = [ ];
122 description = ''
123 List of moderator users. When set, a config file is generated
124 automatically with the standard admin and moderator roles.
125 Ignored if `configFile` is set.
126 '';
127 example = lib.literalExpression ''
128 [
129 { did = "did:plc:abc123"; role = "admin"; handle = "alice.bsky.social"; note = "Platform owner"; }
130 { did = "did:plc:def456"; role = "moderator"; handle = "bob.bsky.social"; }
131 ]
132 '';
133 };
134 };
135
136 smtp = {
137 enable = lib.mkOption {
138 type = lib.types.bool;
139 default = false;
140 description = ''
141 Enable SMTP email notifications for join requests.
142 SMTP credentials (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM)
143 can be provided via environmentFiles.
144 '';
145 };
146
147 host = lib.mkOption {
148 type = lib.types.str;
149 default = "";
150 description =
151 "SMTP server hostname. Can also be set via SMTP_HOST in an environment file.";
152 example = "smtp.example.com";
153 };
154
155 port = lib.mkOption {
156 type = lib.types.nullOr lib.types.port;
157 default = null;
158 description =
159 "SMTP server port. Can also be set via SMTP_PORT in an environment file.";
160 };
161
162 from = lib.mkOption {
163 type = lib.types.str;
164 default = "";
165 description =
166 "Sender address for outgoing email. Can also be set via SMTP_FROM in an environment file.";
167 example = "noreply@arabica.example.com";
168 };
169 };
170
171 environmentFiles = lib.mkOption {
172 type = lib.types.listOf lib.types.path;
173 default = [ ];
174 description = ''
175 List of environment files to load into the systemd service.
176 Useful for secrets like SMTP_USER and SMTP_PASS that should
177 not be stored in the Nix store.
178 '';
179 example = lib.literalExpression ''[ "/run/secrets/arabica.env" ]'';
180 };
181
182 oauth = {
183 clientId = lib.mkOption {
184 type = lib.types.str;
185 description = ''
186 OAuth client ID. This should be the URL to your client-metadata.json endpoint.
187 For example: https://arabica.example.com/client-metadata.json
188 '';
189 example = "https://arabica.example.com/client-metadata.json";
190 };
191
192 redirectUri = lib.mkOption {
193 type = lib.types.str;
194 description = ''
195 OAuth redirect URI. This is where users are redirected after authentication.
196 For example: https://arabica.example.com/oauth/callback
197 '';
198 example = "https://arabica.example.com/oauth/callback";
199 };
200 };
201
202 dataDir = lib.mkOption {
203 type = lib.types.path;
204 default = "/var/lib/arabica";
205 description =
206 "Directory where arabica stores its data (OAuth sessions, etc.).";
207 };
208
209 user = lib.mkOption {
210 type = lib.types.str;
211 default = "arabica";
212 description = "User account under which arabica runs.";
213 };
214
215 group = lib.mkOption {
216 type = lib.types.str;
217 default = "arabica";
218 description = "Group under which arabica runs.";
219 };
220
221 otelEndpoint = lib.mkOption {
222 type = lib.types.nullOr lib.types.str;
223 default = null;
224 description =
225 "OTLP HTTP endpoint for OpenTelemetry traces (e.g. localhost:4318).";
226 example = "localhost:4318";
227 };
228
229 openFirewall = lib.mkOption {
230 type = lib.types.bool;
231 default = false;
232 description = "Whether to open the firewall for the arabica port.";
233 };
234 };
235
236 config = lib.mkIf cfg.enable {
237 users.users.${cfg.user} = lib.mkIf (cfg.user == "arabica") {
238 isSystemUser = true;
239 group = cfg.group;
240 description = "Arabica service user";
241 home = cfg.dataDir;
242 createHome = true;
243 };
244
245 users.groups.${cfg.group} = lib.mkIf (cfg.group == "arabica") { };
246
247 systemd.services.arabica = {
248 description = "Arabica Coffee Brew Tracking Service";
249 wantedBy = [ "multi-user.target" ];
250 after = [ "network.target" ];
251
252 serviceConfig = {
253 Type = "simple";
254 User = cfg.user;
255 Group = cfg.group;
256 ExecStart = "${cfg.package}/bin/arabica";
257 Restart = "on-failure";
258 RestartSec = "10s";
259
260 EnvironmentFile = cfg.environmentFiles;
261
262 # Security hardening
263 NoNewPrivileges = true;
264 PrivateTmp = true;
265 ProtectSystem = "strict";
266 ProtectHome = true;
267 ReadWritePaths = [ cfg.dataDir ];
268 ProtectKernelTunables = true;
269 ProtectKernelModules = true;
270 ProtectControlGroups = true;
271 RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
272 RestrictNamespaces = true;
273 LockPersonality = true;
274 RestrictRealtime = true;
275 RestrictSUIDSGID = true;
276 MemoryDenyWriteExecute = true;
277 SystemCallArchitectures = "native";
278 CapabilityBoundingSet = "";
279 };
280
281 environment = {
282 PORT = toString cfg.settings.port;
283 LOG_LEVEL = cfg.settings.logLevel;
284 LOG_FORMAT = cfg.settings.logFormat;
285 SECURE_COOKIES = lib.boolToString cfg.settings.secureCookies;
286 OAUTH_CLIENT_ID = cfg.oauth.clientId;
287 OAUTH_REDIRECT_URI = cfg.oauth.redirectUri;
288 ARABICA_DB_PATH = "${cfg.dataDir}/arabica.db";
289 } // lib.optionalAttrs (effectiveConfigPath != null) {
290 ARABICA_MODERATORS_CONFIG = toString effectiveConfigPath;
291 } // lib.optionalAttrs (cfg.smtp.enable && cfg.smtp.host != "") {
292 SMTP_HOST = cfg.smtp.host;
293 } // lib.optionalAttrs (cfg.smtp.enable && cfg.smtp.port != null) {
294 SMTP_PORT = toString cfg.smtp.port;
295 } // lib.optionalAttrs (cfg.smtp.enable && cfg.smtp.from != "") {
296 SMTP_FROM = cfg.smtp.from;
297 } // lib.optionalAttrs (cfg.otelEndpoint != null) {
298 OTEL_EXPORTER_OTLP_ENDPOINT = cfg.otelEndpoint;
299 };
300 };
301
302 networking.firewall =
303 lib.mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.settings.port ]; };
304 };
305}