{ pkgs, lib, ... }: let inherit (lib) mkEnableOption mkOption types; in { resources.garage = { description = "Garage storage configuration for an application."; request = { ... }: { _class = "fediversity-resource-request"; options = { enable = mkEnableOption "Enable a Garage server on the machine"; nixos-configuration = mkOption { description = "The NixOS configuration module to include."; type = types.functionTo types.unspecified; default = _: { }; }; ensureBuckets = mkOption { type = types.attrsOf ( types.submodule { options = { website = mkOption { type = types.bool; default = false; }; # I think setting corsRules should allow another website to show images from your bucket corsRules = { enable = mkEnableOption "CORS Rules"; allowedHeaders = mkOption { type = types.listOf types.str; default = [ ]; }; allowedMethods = mkOption { type = types.listOf types.str; default = [ ]; }; allowedOrigins = mkOption { type = types.listOf types.str; default = [ ]; }; }; aliases = mkOption { type = types.listOf types.str; default = [ ]; }; }; } ); default = { }; }; ensureKeys = mkOption { type = types.attrsOf ( types.submodule { options = { s3AccessKeyFile = mkOption { type = types.path; }; s3SecretKeyFile = mkOption { type = types.path; }; # TODO: assert at least one of these is true # NOTE: this currently needs to be done at the top level module ensureAccess = mkOption { type = types.attrsOf ( types.submodule { options = { read = mkOption { type = types.bool; default = false; }; write = mkOption { type = types.bool; default = false; }; owner = mkOption { type = types.bool; default = false; }; }; } ); default = [ ]; }; }; } ); default = { }; }; }; }; policy = { config, ... }: { _class = "fediversity-resource-policy"; options = { rpc = { port = mkOption { type = types.int; default = 3901; }; }; api = { domain = mkOption { type = types.str; # default = "s3.garage.${config.fediversity.domain}"; default = "s3.garage.fediversity.net"; }; port = mkOption { type = types.int; default = 3900; }; url = mkOption { type = types.str; default = "http://${config.api.domain}:${toString config.api.port}"; }; }; web = { rootDomain = mkOption { type = types.str; # default = "web.garage.${config.fediversity.domain}"; default = "web.garage.fediversity.net"; }; internalPort = mkOption { type = types.int; default = 3902; }; domainForBucket = mkOption { type = types.functionTo types.str; default = bucket: "${bucket}.${config.web.rootDomain}"; }; urlForBucket = mkOption { type = types.functionTo types.str; default = bucket: "http://${config.web.domainForBucket bucket}"; }; }; }; config = { resource-type = types.submodule { options = { garageSide = mkOption { # type = types.unspecified; # default = { }; type = types.listOf types.unspecified; default = [ ]; }; applicationSide = mkOption { # type = types.attrsOf types.unspecified; # default = { }; type = types.listOf types.unspecified; default = [ ]; }; }; }; apply = requests: let applicationSide = map (req: req.nixos-configuration config) requests; garageSide = let # generate one using openssl (somehow) # XXX: when importing, garage tells you importing is only meant for keys previously generated by garage. is it okay to generate them using openssl? it seems to work fine snakeoil_key = { id = "GK22a15201acacbd51cd43e327"; secret = "82b2b4cbef27bf8917b350d5b10a87c92fa9c8b13a415aeeea49726cf335d74e"; }; inherit (builtins) toString; inherit (lib) optionalString concatStringsSep mkIf; inherit (lib.strings) escapeShellArg; inherit (lib.attrsets) filterAttrs mapAttrs'; concatMapAttrs = scriptFn: attrset: concatStringsSep "\n" (lib.mapAttrsToList scriptFn attrset); ensureAccessScriptFn = key: bucket: { read, write, owner, }: '' garage bucket allow ${optionalString read "--read"} ${optionalString write "--write"} ${optionalString owner "--owner"} \ ${escapeShellArg bucket} --key ${escapeShellArg key} ''; ensureKeyScriptFn = key: { s3AccessKeyFile, s3SecretKeyFile, ensureAccess, }: '' ## FIXME: Check whether the key exist and skip this step if that is the case. Get rid of this `|| :` garage key import --yes -n ${escapeShellArg key} $(cat ${escapeShellArg s3AccessKeyFile}) $(cat ${escapeShellArg s3SecretKeyFile}) || : ${concatMapAttrs (ensureAccessScriptFn key) ensureAccess} ''; ensureBucketScriptFn = bucket: { website, aliases, corsRules, }: let bucketArg = escapeShellArg bucket; corsRulesJSON = escapeShellArg ( builtins.toJSON { CORSRules = [ { AllowedHeaders = corsRules.allowedHeaders; AllowedMethods = corsRules.allowedMethods; AllowedOrigins = corsRules.allowedOrigins; } ]; } ); in '' # garage bucket info tells us if the bucket already exists garage bucket info ${bucketArg} || garage bucket create ${bucketArg} # TODO: should this --deny the website if `website` is false? ${optionalString website '' garage bucket website --allow ${bucketArg} ''} ${concatStringsSep "\n" ( map (alias: '' garage bucket alias ${bucketArg} ${escapeShellArg alias} '') aliases )} ${optionalString corsRules.enable '' garage bucket allow --read --write --owner ${bucketArg} --key tmp # TODO: endpoint-url should not be hard-coded aws --region ${config.settings.s3_api.s3_region} --endpoint-url ${config.api.url} s3api put-bucket-cors --bucket ${bucketArg} --cors-configuration ${corsRulesJSON} garage bucket deny --read --write --owner ${bucketArg} --key tmp ''} ''; value = { forceSSL = true; enableACME = true; locations."/" = { proxyPass = "http://localhost:3902"; extraConfig = '' ## copied from https://garagehq.deuxfleurs.fr/documentation/cookbook/reverse-proxy/ proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Disable buffering to a temporary file. proxy_max_temp_file_size 0; ## NOTE: This page suggests many more options for the object storage ## proxy. We should take a look. ## https://docs.joinmastodon.org/admin/optional/object-storage-proxy/ ''; }; }; ensureBucketsScript = lib.concatStringsSep "\n" (lib.lists.map ( { ensureBuckets }: concatMapAttrs ensureBucketScriptFn ensureBuckets )) requests; ensureKeysScript = lib.concatStringsSep "\n" (lib.lists.map ( { ensureKeys }: concatMapAttrs ensureKeyScriptFn ensureKeys )) requests; baseCfg = { config = mkIf config.enable { environment.systemPackages = [ pkgs.minio-client pkgs.awscli ]; ## REVIEW: Do we want to reverse proxy the RPC and API ports? In fact, ## shouldn't we just get rid of RPC at all, we're not using it. networking.firewall.allowedTCPPorts = [ 80 443 config.api.port config.rpc.port ]; services.garage = { enable = true; package = pkgs.garage_0_9; settings = { replication_mode = "none"; # TODO: use a secret file rpc_secret = "d576c4478cc7d0d94cfc127138cbb82018b0155c037d1c827dfb6c36be5f6625"; # TODO: why does this have to be set? is there not a sensible default? rpc_bind_addr = "[::]:${toString config.rpc.port}"; rpc_public_addr = "[::1]:${toString config.rpc.port}"; s3_api.api_bind_addr = "[::]:${toString config.api.port}"; s3_web.bind_addr = "[::]:${toString config.web.internalPort}"; s3_web.root_domain = ".${config.web.rootDomain}"; index = "index.html"; s3_api.s3_region = "garage"; s3_api.root_domain = ".${config.api.domain}"; }; }; services.nginx.enable = true; systemd.services.ensure-garage = { after = [ "garage.service" ]; wantedBy = [ "garage.service" ]; serviceConfig.Type = "oneshot"; path = [ config.package pkgs.perl pkgs.awscli ]; script = '' set -xeuo pipefail # Give Garage time to start up by waiting until somethings speaks HTTP # behind Garage's API URL. until ${pkgs.curl}/bin/curl -sio /dev/null ${config.api.url}; do sleep 1; done # XXX: this is very sensitive to being a single instance # (doing the bare minimum to get garage up and running) # also, it's crazy that we have to parse command output like this # TODO: talk to garage maintainer about making this nicer to work with in Nix # before I do that though, I should figure out how setting it up across multiple machines will work GARAGE_ID=$(garage node id 2>/dev/null | perl -ne '/(.*)@.*/ && print $1') garage layout assign -z g1 -c 1G $GARAGE_ID LAYOUT_VER=$(garage layout show | perl -ne '/Current cluster layout version: (\d*)/ && print $1') garage layout apply --version $((LAYOUT_VER + 1)) # XXX: this is a hack because we want to write to the buckets here but we're not guaranteed any access keys # TODO: generate this key here rather than using a well-known key # TODO: if the key already exists, we get an error; hacked with this `|| :` which needs to be removed garage key import --yes -n tmp ${snakeoil_key.id} ${snakeoil_key.secret} || : export AWS_ACCESS_KEY_ID=${snakeoil_key.id}; export AWS_SECRET_ACCESS_KEY=${snakeoil_key.secret}; ${ensureBucketsScript} ${ensureKeysScript} # garage doesn't like re-adding keys that once existed, so we can't delete / recreate it every time # garage key delete ${snakeoil_key.id} --yes ''; }; }; }; in [ baseCfg ] ++ (lib.lists.map ( { ensureBuckets }: { ## Create a proxy from .web.garage. to localhost:3902 for ## each bucket that has `website = true`. services.nginx.virtualHosts = mapAttrs' (bucket: _: { name = config.web.domainForBucket bucket; inherit value; }) (filterAttrs (_: { website, ... }: website) ensureBuckets); } ) requests); in { inherit garageSide applicationSide; }; }; }; }; }