feat(web): provide a directory listing via s3-revproxy

Thanks to Jade Lovelace who built all this machinery for Lix initially.

Signed-off-by: Raito Bezarius <masterancpp@gmail.com>
This commit is contained in:
raito 2024-08-31 00:22:27 +02:00
parent 9063138156
commit c38e9b482f
9 changed files with 345 additions and 1 deletions

View file

@ -10,7 +10,7 @@
networking.domain = "infra.forkos.org"; networking.domain = "infra.forkos.org";
bagel.sysadmin.enable = true; bagel.sysadmin.enable = true;
# Buildbot is proxied. # Newsletter is proxied.
bagel.raito.v6-proxy-awareness.enable = true; bagel.raito.v6-proxy-awareness.enable = true;
bagel.newsletter = { bagel.newsletter = {
enable = true; enable = true;
@ -27,6 +27,17 @@
}; };
}; };
bagel.services.s3-revproxy = {
enable = true;
domain = "forkos.org";
s3.apiUrl = "s3.delroth.net";
targets = {
channels = "bagel-channels";
releases = "bagel-releases";
channel-scripts-test = "bagel-channel-scripts-test";
};
};
i18n.defaultLocale = "en_US.UTF-8"; i18n.defaultLocale = "en_US.UTF-8";
system.stateVersion = "24.05"; system.stateVersion = "24.05";

View file

@ -3,5 +3,6 @@
iusb-spoof = final.callPackage ./iusb-spoof.nix {}; iusb-spoof = final.callPackage ./iusb-spoof.nix {};
u-root = final.callPackage ./u-root {}; u-root = final.callPackage ./u-root {};
pyroscope = final.callPackage ./pyroscope {}; pyroscope = final.callPackage ./pyroscope {};
s3-revproxy = final.callPackage ./s3-revproxy {};
}) })
] ]

View file

@ -0,0 +1,39 @@
# Originally written by Jade Lovelace for Lix.
{ lib, buildGoModule, fetchFromGitHub }:
buildGoModule rec {
pname = "s3-revproxy";
version = "4.15.0";
src = fetchFromGitHub {
owner = "oxyno-zeta";
repo = "s3-proxy";
rev = "v${version}";
hash = "sha256-q0cfAo8Uz7wtKljmSDaJ320bjg2yXydvvxubAsMKzbc=";
};
vendorHash = "sha256-dOwNQtTfOCQcjgNBV/FeWdwbW9xi1OK5YD7PBPPDKOQ=";
ldflags = [
"-X github.com/oxyno-zeta/s3-proxy/pkg/s3-proxy/version.Version=${version}"
"-X github.com/oxyno-zeta/s3-proxy/pkg/s3-proxy/version.Metadata="
];
postPatch = ''
# Refer to the included templates in the package instead of cwd-relative
sed -i "s#Path = \"templates/#Path = \"$out/share/s3-revproxy/templates/#" pkg/s3-proxy/config/config.go
'';
postInstall = ''
mkdir -p $out/share/s3-revproxy
cp -r templates/ $out/share/s3-revproxy/templates
'';
meta = {
description = "S3 Reverse Proxy with GET, PUT and DELETE methods and authentication (OpenID Connect and Basic Auth)";
homepage = "https://oxyno-zeta.github.io/s3-proxy";
# hm, not having a maintainers entry is kind of inconvenient
maintainers = [ ];
licenses = lib.licenses.asl20;
mainProgram = "s3-proxy";
};
}

View file

@ -11,5 +11,6 @@
./baremetal-builder ./baremetal-builder
./buildbot ./buildbot
./newsletter ./newsletter
./s3-revproxy
]; ];
} }

View file

@ -0,0 +1,131 @@
# 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/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 = [ "*" ];
};
};
targets = mapAttrs (name: bucket: mkTarget { inherit name bucket; }) cfg.targets;
};
environmentFile = config.age.secrets.s3-revproxy-api-keys.path;
};
};
}

View file

@ -0,0 +1,69 @@
# Originally, written by Jade Lovelace for Lix.
{ config, pkgs, lib, ... }:
let cfg = config.services.s3-revproxy;
settingsGenerator = pkgs.formats.yaml {};
# Needs to be in a directory, so we might as well implement autoreload, why not!
configFile = settingsGenerator.generate "config.yaml" cfg.settings;
inherit (lib) types;
in
{
options.services.s3-revproxy = {
enable = lib.mkEnableOption "s3 reverse proxy";
package = lib.mkPackageOption pkgs "s3-revproxy" {};
settings = lib.mkOption {
default = { };
type = settingsGenerator.type;
description = ''
Settings to use for the service. See the documentation at https://oxyno-zeta.github.io/s3-proxy/configuration/structure/
'';
};
environmentFile = lib.mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Environment file to use for s3-revproxy.
'';
};
};
config = lib.mkIf cfg.enable {
environment.etc."s3-revproxy/config.yaml".source = configFile;
systemd.services.s3-revproxy = {
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${lib.getExe cfg.package} --config /etc/s3-revproxy";
DynamicUser = true;
CapabilityBoundingSet = "";
NoNewPrivileges = true;
PrivateTmp = true;
PrivateUsers = true;
PrivateDevices = true;
ProtectHome = true;
ProtectClock = true;
ProtectProc = "noaccess";
ProcSubset = "pid";
UMask = "0077";
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectControlGroups = true;
ProtectHostname = true;
RestrictSUIDSGID = true;
RestrictRealtime = true;
RestrictNamespaces = true;
LockPersonality = true;
RemoveIPC = true;
SystemCallFilter = [ "@system-service" "~@privileged" ];
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
MemoryDenyWriteExecute = true;
SystemCallArchitectures = "native";
EnvironmentFile = lib.optionals (cfg.environmentFile != null) [ cfg.environmentFile ];
};
};
};
}

View file

@ -0,0 +1,37 @@
{{/* SPDX-License-Identifier: Apache-2.0 */}}
{{/* SPDX-FileCopyrightText: 2024 s3-proxy contributors */}}
{{- /* This function will allow to get user identifier. */ -}}
{{- define "main.userIdentifier" -}}
{{- if .User -}}
{{- .User.GetIdentifier -}}
{{- end -}}
{{- end -}}
{{- /* This function will allow to get the content type header from "Accept" header */ -}}
{{- define "main.headers.contentType" -}}
{{- if contains "application/json" (.Request.Header.Get "Accept") -}}
application/json; charset=utf-8
{{- else -}}
text/html; charset=utf-8
{{- end -}}
{{- end -}}
{{- /* This will forge the json output of an error */ -}}
{{- define "main.body.errorJsonBody" -}}
{"error": {{ .Error.Error | toJson }}}
{{- end -}}
{{- define "notFoundErrorBody" -}}
{{- if contains "application/json" (.Request.Header.Get "Accept") -}}
{{ template "main.body.errorJsonBody" . }}
{{- else -}}
<!DOCTYPE html>
<html>
<body>
<h1>Not Found {{ .Request.URL.Path }}</h1>
</body>
</html>
{{- end -}}
{{- end -}}

View file

@ -0,0 +1,52 @@
{{/* SPDX-License-Identifier: Apache-2.0 */}}
{{/* SPDX-FileCopyrightText: 2024 s3-proxy contributors */}}
{{- $root := . -}}
{{- if eq (len .Entries) 0 -}}
{{- include "notFoundErrorBody" . -}}
{{- else -}}
{{- if contains "application/json" (.Request.Header.Get "Accept") -}}
[
{{- $maxLen := len $root.Entries -}}
{{- range $index, $entry := $root.Entries -}}
{"name": {{ $entry.Name | toJson -}}
,"etag": {{ $entry.ETag | toJson -}}
,"type": {{ $entry.Type | toJson -}}
,"size": {{ $entry.Size | toJson -}}
,"path": {{ $entry.Path | toJson -}}
,"lastModified": {{ $entry.LastModified | date "2006-01-02T15:04:05Z07:00" | toJson -}}
}{{- if ne $index (sub $maxLen 1) -}},{{- end -}}
{{- end -}}
]
{{- else -}}
<!DOCTYPE html>
<html>
<body>
<h1>Index of {{ .Request.URL.Path }}</h1>
<table style="width:100%">
<thead>
<tr>
<th style="border-right:1px solid black;text-align:start">Entry</th>
<th style="border-right:1px solid black;text-align:start">Size</th>
<th style="border-right:1px solid black;text-align:start">Last modified</th>
</tr>
</thead>
<tbody style="border-top:1px solid black">
<tr>
<td style="border-right:1px solid black;padding: 0 5px"><a href="..">..</a></td>
<td style="border-right:1px solid black;padding: 0 5px"> - </td>
<td style="padding: 0 5px"> - </td>
</tr>
{{- range .Entries }}
<tr>
<td style="border-right:1px solid black;padding: 0 5px"><a href="{{ .Path }}">{{ .Name }}</a></td>
<td style="border-right:1px solid black;padding: 0 5px">{{- if eq .Type "FOLDER" -}} - {{- else -}}{{ .Size | humanSize }}{{- end -}}</td>
<td style="padding: 0 5px">{{ .LastModified }}</td>
</tr>
{{- end }}
</tbody>
</table>
</body>
</html>
{{- end -}}
{{- end -}}

View file

@ -0,0 +1,3 @@
{{/* SPDX-License-Identifier: Apache-2.0 */}}
{{/* SPDX-FileCopyrightText: 2024 s3-proxy contributors */}}
{{- include "notFoundErrorBody" . -}}