# Originally written by Jade Lovelace for Lix. # Adapted for ForkOS. { lib, config, ... }: let inherit (lib) mkEnableOption mkIf types mkOption mapAttrs mapAttrs' nameValuePair; cfg = config.bagel.services.s3-revproxy; s3RevproxyPort = 10652; mkTarget = { name, bucket ? name }: { mount = { host = "${name}.${cfg.domain}"; path = [ "/" ]; }; actions.GET = { enabled = true; config = { # e.g. /2.90 will 404, so it will redirect to /2.90/ if it is a directory redirectWithTrailingSlashForNotFoundFile = true; indexDocument = "index.html"; }; }; bucket = { name = bucket; region = "garage"; s3Endpoint = "https://${cfg.s3.apiUrl}"; credentials = { accessKey.env = "AWS_ACCESS_KEY_ID"; secretKey.env = "AWS_SECRET_KEY"; }; }; }; # Makes a subdomain that gets proxied through s3-proxy to provide directory # listings and reasonable 404 pages. # This is not used on cache, since there a directory listing for cache is a # liability at best. mkProxiedSubdomain = subdomain: { enableACME = true; forceSSL = true; locations."/" = { recommendedProxySettings = true; proxyPass = "http://127.0.0.1:${toString s3RevproxyPort}/"; }; }; in { options.bagel.services.s3-revproxy = { enable = mkEnableOption "a S3 reverse proxy"; domain = mkOption { type = types.str; }; s3 = { apiUrl = mkOption { type = types.str; description = "An URL to the S3 API"; }; }; targets = mkOption { type = types.attrsOf types.str; description = '' A mapping between a nice name and a bucket name. ''; }; }; imports = [ ./module.nix ]; config = mkIf cfg.enable { age.secrets.s3-revproxy-api-keys.file = ../../secrets/floral/s3-revproxy-api-keys.age; # For each target, generate an entry that passes it to the s3-revproxy. services.nginx.virtualHosts = mapAttrs' (subdomain: _: nameValuePair "${subdomain}.${cfg.domain}" (mkProxiedSubdomain subdomain)) cfg.targets; # this solves garage supporting neither anonymous access nor automatic # directory indexing by simply ignoring garage's web server and replacing it # with overengineered golang instead. services.s3-revproxy = { enable = true; settings = { templates = { helpers = [ ./s3-revproxy-templates/_helpers.tpl ]; notFoundError = { headers = { "Content-Type" = "{{ template \"main.headers.contentType\" . }}"; }; status = "404"; }; folderList = { path = ./s3-revproxy-templates/folder-list.tpl; headers = { "Content-Type" = "{{ template \"main.headers.contentType\" . }}"; }; # empty s3 directories are not real and cannot hurt you. # due to redirectWithTrailingSlashForNotFoundFile, garbage file names # get redirected as folders, which then appear as empty, yielding # poor UX. status = '' {{- if eq (len .Entries) 0 -}} 404 {{- else -}} 200 {{- end -}} ''; }; }; /* For metrics and debugging (e.g. pulling the config) internalServer = { listenAddr = "127.0.0.1"; port = 1337; }; */ server = { listenAddr = "127.0.0.1"; port = s3RevproxyPort; # it's going right into nginx, so no point compress.enabled = false; cors = { enabled = true; allowMethods = [ "GET" ]; allowOrigins = [ "*" ]; }; cache = { noCacheEnabled = false; # Taken from the original Perl script. # maxage=600: Serve from cache for 5 minutes. # stale-while-revaliadate=1800: Serve from cache while updating in the background for 30 minutes. # https://web.dev/stale-while-revalidate/ # https://developer.fastly.com/learning/concepts/cache-freshness/ cacheControl = "maxage=600,stale-while-revalidate=1800,public"; }; }; targets = mapAttrs (name: bucket: mkTarget { inherit name bucket; }) cfg.targets; }; environmentFile = config.age.secrets.s3-revproxy-api-keys.path; }; }; }