Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
at main 305 lines 9.7 kB view raw
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}