From 752b01728b4c976545d374dfbf2be4e96eb6fd0c Mon Sep 17 00:00:00 2001 From: mulatta <67085791+mulatta@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:22:16 +0900 Subject: [PATCH 1/7] zui: init at 0-unstable-2026-10-10 Build the zot web UI from source instead of embedding the prebuilt release tarball. The vite output is byte-identical to the upstream commit-ce3819c zui.tgz, so the registry serves the same assets while the UI stays auditable and patchable. Change-Id: Id08ca81190fbe8cb1d14cceb8f6dcfcd58eff77c --- packages/flake-module.nix | 1 + packages/zui/default.nix | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 packages/zui/default.nix diff --git a/packages/flake-module.nix b/packages/flake-module.nix index 7895f230..67c1e53c 100644 --- a/packages/flake-module.nix +++ b/packages/flake-module.nix @@ -13,6 +13,7 @@ slack-cli = pkgs.callPackage ./slack-cli { buildGoModule = pkgs.buildGo127Module; }; slack-group-sync = pkgs.callPackage ./slack-group-sync { }; updater = pkgs.callPackage ./updater { }; + zui = pkgs.callPackage ./zui { }; } // lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { installer = pkgs.callPackage ./image-installer { inherit pkgs self; }; diff --git a/packages/zui/default.nix b/packages/zui/default.nix new file mode 100644 index 00000000..541a5241 --- /dev/null +++ b/packages/zui/default.nix @@ -0,0 +1,27 @@ +{ + lib, + buildNpmPackage, + fetchFromGitHub, +}: +buildNpmPackage { + pname = "zui"; + # Match the UI commit selected by the zot v2.1.23 release Makefile. + version = "0-unstable-2026-10-10"; + src = fetchFromGitHub { + owner = "project-zot"; + repo = "zui"; + rev = "ce3819cdad951940adc095870cdd16314af579a5"; + hash = "sha256-FwqpghOsuSLRsJN/d7uxO1Tp8RGPZompkdQAdtPeenc="; + }; + npmDepsHash = "sha256-gcO5P5VRkOGfv6HmdONyXO8kHmC6ZsF+rn6vP+WYR8I="; + installPhase = '' + runHook preInstall + cp -r build $out + runHook postInstall + ''; + meta = { + description = "Web UI for the zot OCI registry"; + homepage = "https://github.com/project-zot/zui"; + license = lib.licenses.asl20; + }; +} From 560d7795a66fd21428cbda3d77335aa1d5c6b0c8 Mon Sep 17 00:00:00 2001 From: mulatta <67085791+mulatta@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:32:20 +0900 Subject: [PATCH 2/7] zot: init at 2.1.23 Build the full extension set with the source-built UI so the registry on eta can replace distribution and docker_auth with OIDC, group ACLs, API keys, and retention. Change-Id: Id176a0bfc567e4a3ce0f1de7550d46c431aff66b --- packages/flake-module.nix | 2 ++ packages/zot/default.nix | 67 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 packages/zot/default.nix diff --git a/packages/flake-module.nix b/packages/flake-module.nix index 67c1e53c..b16a3850 100644 --- a/packages/flake-module.nix +++ b/packages/flake-module.nix @@ -4,6 +4,7 @@ { pkgs, lib, + self', ... }: { @@ -14,6 +15,7 @@ slack-group-sync = pkgs.callPackage ./slack-group-sync { }; updater = pkgs.callPackage ./updater { }; zui = pkgs.callPackage ./zui { }; + zot = pkgs.callPackage ./zot { inherit (self'.packages) zui; }; } // lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { installer = pkgs.callPackage ./image-installer { inherit pkgs self; }; diff --git a/packages/zot/default.nix b/packages/zot/default.nix new file mode 100644 index 00000000..6f76d5fe --- /dev/null +++ b/packages/zot/default.nix @@ -0,0 +1,67 @@ +{ + lib, + buildGo127Module, + fetchFromGitHub, + zui, +}: +let + version = "2.1.23"; + extensions = [ + "events" + "imagetrust" + "lint" + "metrics" + "mgmt" + "profile" + "scrub" + "search" + "sync" + "ui" + "userprefs" + ]; + binaryType = "-${lib.concatStringsSep "-" extensions}"; +in +buildGo127Module (finalAttrs: { + pname = "zot"; + inherit version; + src = fetchFromGitHub { + owner = "project-zot"; + repo = "zot"; + tag = "v${version}"; + hash = "sha256-Q21qFoZzOpJe7oJP4RSGiBvhPF9znCF2ndegsVSHQnY="; + }; + vendorHash = "sha256-ILHn4EUCHLpwdgSj/CBlX58R1D2zJhr+H6HkcAOdhis="; + subPackages = [ "cmd/zot" ]; + tags = extensions; + buildFlags = [ "-buildmode=pie" ]; + env = { + CGO_ENABLED = 0; + GOEXPERIMENT = "jsonv2"; + }; + preBuild = '' + cp -r --no-preserve=mode ${zui} pkg/extensions/build + ''; + ldflags = [ + "-s" + "-w" + "-X zotregistry.dev/zot/v2/pkg/buildinfo.ReleaseTag=v${version}" + "-X zotregistry.dev/zot/v2/pkg/buildinfo.Commit=v${version}-0-gd7915e8" + "-X zotregistry.dev/zot/v2/pkg/buildinfo.BinaryType=${binaryType}" + "-X zotregistry.dev/zot/v2/pkg/buildinfo.GoVersion=go${finalAttrs.passthru.go.version}" + ]; + # Upstream integration tests require network services and image fixtures. + doCheck = false; + doInstallCheck = true; + installCheckPhase = '' + runHook preInstallCheck + "$out/bin/zot" --version | grep -F '${version}' + "$out/bin/zot" --version | grep -F -- '${binaryType}' + runHook postInstallCheck + ''; + meta = { + description = "OCI image registry with authentication, search and retention"; + homepage = "https://zotregistry.dev"; + license = lib.licenses.asl20; + mainProgram = "zot"; + }; +}) From a7e1841109b13bc9a865df0d9e3b127d1dc54601 Mon Sep 17 00:00:00 2001 From: mulatta <67085791+mulatta@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:32:21 +0900 Subject: [PATCH 3/7] zot: add nixos module Keep runtime credentials out of the store and validate configuration before activation. A stable /etc path lets ACL and retention changes hot-reload without interrupting registry clients; other settings still need a restart. Retention reloads only while the running policy already needs metaDB. Docker-built images need http.compat docker2s2 because zot accepts only OCI manifests by default. The service runs as a static user so local storage outside StateDirectory, such as a separate data array, can be owned by it. Change-Id: I11b1966b5696b2762011929254f85be1d37d6a95 --- modules/zot/default.nix | 107 +++++++++++++++++++++++++++++++++ modules/zot/validate-config.py | 31 ++++++++++ 2 files changed, 138 insertions(+) create mode 100644 modules/zot/default.nix create mode 100644 modules/zot/validate-config.py diff --git a/modules/zot/default.nix b/modules/zot/default.nix new file mode 100644 index 00000000..c4302982 --- /dev/null +++ b/modules/zot/default.nix @@ -0,0 +1,107 @@ +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.services.zot; + json = pkgs.formats.json { }; + rawConfig = json.generate "zot.json" cfg.settings; + rootDirectory = cfg.settings.storage.rootDirectory or "/var/lib/zot"; + externalRoot = !lib.hasPrefix "/var/lib/zot" rootDirectory; + checkedConfig = + pkgs.runCommand "zot-checked.json" + { + nativeBuildInputs = [ + cfg.package + pkgs.python3 + ]; + } + '' + python3 ${./validate-config.py} ${rawConfig} validation.json + zot verify validation.json + cp ${rawConfig} "$out" + ''; +in +{ + options.services.zot = { + enable = lib.mkEnableOption "zot OCI registry"; + package = lib.mkOption { + type = lib.types.package; + default = pkgs.callPackage ../../packages/zot { zui = pkgs.callPackage ../../packages/zui { }; }; + description = "Zot server package."; + }; + settings = lib.mkOption { + inherit (json) type; + default = { }; + description = '' + Freeform zot JSON configuration. Keep secrets outside the Nix store: + reference credentials as /run/credentials/zot.service/NAME. + JSON credentials (OIDC and session keys) need a .json credential name, + since zot chooses their decoder from the filename extension. + A local storage.rootDirectory outside /var/lib/zot is created for the + zot user and made writable. + Zot hot-reloads its supported settings (including ACL and retention). + For changes outside that set, such as listener or storage paths, zot + logs a warning; explicitly restart zot.service to apply those changes. + ''; + }; + credentials = lib.mkOption { + type = lib.types.attrsOf lib.types.path; + default = { }; + description = "Runtime secret paths loaded through systemd LoadCredential."; + }; + environmentFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Runtime environment file, for example for S3 credentials."; + }; + }; + config = lib.mkIf cfg.enable { + environment.etc."zot/config.json".source = checkedConfig; + # A static user, unlike DynamicUser, can own storage outside StateDirectory. + users.users.zot = { + isSystemUser = true; + group = "zot"; + }; + users.groups.zot = { }; + systemd.tmpfiles.rules = lib.optional externalRoot "d ${rootDirectory} 0700 zot zot - -"; + systemd.services.zot = { + description = "Zot OCI registry"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + # v2.1.23 polls file identity, including /etc symlink replacements. Keep + # settings out of the unit so NixOS switches hot-reload without restart. + restartTriggers = [ ]; + serviceConfig = { + ExecStart = "${lib.getExe cfg.package} serve /etc/zot/config.json"; + LoadCredential = lib.mapAttrsToList (name: path: "${name}:${path}") cfg.credentials; + EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile; + User = "zot"; + Group = "zot"; + StateDirectory = "zot"; + ReadWritePaths = lib.optional externalRoot rootDirectory; + WorkingDirectory = "/var/lib/zot"; + UMask = "0077"; + Restart = "on-failure"; + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictSUIDSGID = true; + LockPersonality = true; + RestrictAddressFamilies = [ + "AF_UNIX" + "AF_INET" + "AF_INET6" + ]; + CapabilityBoundingSet = [ ]; + }; + }; + }; +} diff --git a/modules/zot/validate-config.py b/modules/zot/validate-config.py new file mode 100644 index 00000000..97495f37 --- /dev/null +++ b/modules/zot/validate-config.py @@ -0,0 +1,31 @@ +"""Validate structure without reading runtime secrets into the Nix store.""" + +import json +import sys +from pathlib import Path +from typing import Any + + +def placeholders(value: Any, field: str = "") -> Any: + if isinstance(value, dict): + return {key: placeholders(item, key) for key, item in value.items()} + if isinstance(value, list): + return [placeholders(item, field) for item in value] + if isinstance(value, str) and value.startswith("/run/credentials/zot.service/"): + name = value.removeprefix("/run/credentials/zot.service/") + if not name or "/" in name or name in {".", ".."}: + raise ValueError("Invalid zot credential name") + # Zot selects the secret decoder from the filename extension. + path = Path.cwd() / f"placeholder-{name}-{field}.json" + content = "" + if field == "sessionKeysFile": + content = json.dumps({"hashKey": "0" * 64, "encryptKey": "0" * 32}) + elif field == "credentialsFile": + content = json.dumps({"clientid": "validation", "clientsecret": "validation"}) + path.write_text(content) + return str(path) + return value + + +source, destination = sys.argv[1:] +Path(destination).write_text(json.dumps(placeholders(json.loads(Path(source).read_text())))) From 5b7ad02cb2e8574b8400e49b37c9795169de891b Mon Sep 17 00:00:00 2001 From: mulatta <67085791+mulatta@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:21:23 +0900 Subject: [PATCH 4/7] container-registry: replace distribution with zot Distribution needed docker_auth for token auth and an offline garbage-collection window that stopped the registry daily. zot authenticates directly, collects garbage online, hot-reloads ACL changes, deduplicates blobs, and ships a web UI. zot runs on psi and stores images on its HDD array. psi runs k3s and the release runner, so it pulls and pushes nearly every image, and the campus network drops fast inbound TCP downloads such as R2 blob redirects. Local storage keeps those transfers on the host and lets zot read blobs directly for scanning. eta keeps public TLS and proxies over wg-admin, whose UDP transport is not affected by the drops; psi resolves the public name to its own vhost so local clients need no mirror configuration. The registry becomes private. Lab members sign in through authentik and use UI-issued API keys for docker and skopeo, so access follows authentik groups instead of shared passwords. Repositories are limited to research/ and infra/ so later per-project grants need only a longer pattern. Researchers may push but not overwrite tags, keeping published versions immutable; the CI bot keeps overwrite rights for its BuildKit cache tags. Existing bcrypt hashes become the htpasswd file, keeping CI credentials and a break-glass admin that works without authentik. Docker media types stay accepted so default docker and skopeo pushes and Docker Hub mirrors keep working. Images are reproducible, so the store is not backed up. Change-Id: I35de548f7dc8ddf111f4771efc763d053b75802a --- .sops.nix | 2 +- .sops.yaml | 2 +- hosts/eta.nix | 2 +- hosts/psi.nix | 1 + modules/container-registry/README.md | 86 ++++-- modules/container-registry/auth.nix | 123 --------- modules/container-registry/default.nix | 265 ++++++++++--------- modules/container-registry/edge-proxy.nix | 102 +++---- modules/container-registry/reverse-proxy.nix | 48 ++++ modules/container-registry/secrets.yaml | 43 ++- modules/container-registry/token-signing.crt | 30 --- 11 files changed, 307 insertions(+), 397 deletions(-) delete mode 100644 modules/container-registry/auth.nix create mode 100644 modules/container-registry/reverse-proxy.nix delete mode 100644 modules/container-registry/token-signing.crt diff --git a/.sops.nix b/.sops.nix index ac6618a6..3ff9fcf1 100644 --- a/.sops.nix +++ b/.sops.nix @@ -67,7 +67,7 @@ let "tau" ]; "modules/buildbot/secrets.yaml" = [ "psi" ]; - "modules/container-registry/secrets.yaml" = [ "eta" ]; + "modules/container-registry/secrets.yaml" = [ "psi" ]; "modules/monitoring/secrets.yaml" = [ "rho" ]; "modules/n8n/secrets.yaml" = [ "tau" ]; "terraform/authentik/neko-secrets.yaml" = [ "rho" ]; diff --git a/.sops.yaml b/.sops.yaml index f6010305..4591e8f9 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -65,7 +65,7 @@ creation_rules: path_regex: modules/buildbot/secrets.yaml - key_groups: - age: - - age1zdhqm6ptcnuu3tf2lzcngqmf6eud7jfah7v8falfy5mdksmnfuzq35sq54 + - age18yuzugm0f2l7cc4sdmp5f442af2w897x9l9syeaef3yx3jzc5pjs9zez0f - age1730f3cxdyh56zw8xcvlmpa7u2x7353wu4u0e58kyx24rsefgp98sxehm6s - age15h90xndr3kn5d8et69y5hf54lclmvafhk5a0d3hgc0a9fvjexcaq0t9hvr path_regex: modules/container-registry/secrets.yaml diff --git a/hosts/eta.nix b/hosts/eta.nix index 915ea498..08a9f8e2 100644 --- a/hosts/eta.nix +++ b/hosts/eta.nix @@ -75,7 +75,7 @@ in ./eta/restic ../modules/buildbot/edge-proxy.nix ../modules/gitea-mq - ../modules/container-registry + ../modules/container-registry/edge-proxy.nix ../modules/nginx/edge.nix ../modules/niks3/edge-proxy.nix ../modules/uptermd diff --git a/hosts/psi.nix b/hosts/psi.nix index 3584ce16..3fabe3c2 100644 --- a/hosts/psi.nix +++ b/hosts/psi.nix @@ -39,6 +39,7 @@ in ../modules/monitoring/exporters/systemd-status.nix ../modules/backup/borg.nix ../modules/niks3 + ../modules/container-registry ../modules/vllm ../modules/biodb ../modules/biodb/databases.nix diff --git a/modules/container-registry/README.md b/modules/container-registry/README.md index c809ac30..e537d708 100644 --- a/modules/container-registry/README.md +++ b/modules/container-registry/README.md @@ -1,32 +1,64 @@ # Container Registry -`registry.sjanglab.org` is a public-pull OCI Distribution registry backed by the -private Cloudflare R2 `container-registry` bucket. +`registry.sjanglab.org` is a private zot OCI registry. zot runs on psi and +stores images on the `/data` HDD array. eta terminates public TLS and proxies to +psi over wg-admin; on psi the name resolves to the local nginx vhost, so k3s, +docker and skopeo pull from local disk without leaving the host. -## Access model +## Repository layout + +- `research//:` for project images. +- `research/base/:` for shared runtimes such as PyTorch. +- `infra/:` for service and CI images. -- Pulls are anonymous. -- `docker-push-bot` may pull and push any repository. -- `registry-admin` may pull, push, and delete any repository. This account is - reserved for manual retention and recovery work. -- HTTP deletion remains enabled so `registry-admin` can remove manifests and - offline garbage collection can remove the resulting unreferenced blobs. -- Tagged manifests and referenced layers have no age-based retention policy. +Other paths are reserved for administrators. CI keeps its BuildKit cache next to +each image as `-cache:buildkit`. Only publish redistributable build artifacts. Images must not contain model parameters, research data, internal source code, credentials, or software whose license prohibits redistribution. -## Health image +## Access model + +Every request needs a login; there is no anonymous pull. -Use the data-only OCI health image for anonymous pull checks: +| Identity | `research/**` | `infra/**` | +| --- | --- | --- | +| `sjanglab-admins`, `registry-admin` | read, push, overwrite, delete | same | +| `docker-push-bot` | read, push, overwrite tags | same | +| `sjanglab-researchers` | read, push new tags | none | +| `sjanglab-students` | read | none | -```text -registry.sjanglab.org/sjanglab/registry-healthcheck@sha256:1634c441a4f34568a2c451544653bee64c85cee0030a778be1a8a9612233729c +Researchers cannot overwrite a published tag, so a version always names the +same image. `registry-admin` is a break-glass account for recovery when +authentik is unavailable. There is no retention policy; images stay until an +administrator deletes them. + +## Member login + +Sign in to with authentik. docker and skopeo +cannot use OIDC, so create an API key in the web UI with a 30-day expiry and +use it as the password: + +```bash +docker login registry.sjanglab.org --username --password-stdin ``` -Tag `v1` points to this digest. The image contains only `README.txt` and may be -used for anonymous pull checks. +The username is the authentik account name, which is the Slack email. + +An API key keeps the groups its owner had at their last web login. Removing a +member from authentik does not revoke keys they already created, and only the +owner can delete a key, so keep keys short-lived. + +## Image format + +zot accepts both OCI and Docker manifests and stores them unchanged. Prefer OCI +for new pipelines because it allows zstd layers and manifest annotations: + +```bash +skopeo copy --format oci docker-archive:./image.tar docker://registry.sjanglab.org/research/project/name:tag +docker buildx build --output type=image,name=registry.sjanglab.org/research/project/name:tag,push=true,oci-mediatypes=true . +``` ## CI login @@ -59,9 +91,9 @@ sops -d --extract '["container-registry-admin-password"]' \ docker login registry.sjanglab.org --username registry-admin --password-stdin ``` -Use this account only for manual retention and recovery work. Deleting a -manifest makes its exclusive blobs eligible for the next offline garbage -collection pass. +The same credentials log in to the web UI, where tags can also be deleted. +zot garbage-collects blobs that are no longer referenced while it keeps +serving requests. ## Credential rotation @@ -69,16 +101,12 @@ collection pass. process arguments. 2. Replace both password and password-hash keys for the account being rotated in `secrets.yaml`. -3. Deploy eta. `sops-nix` restarts `docker-auth.service` after rendering the new - hash. +3. Deploy psi. `sops-nix` restarts `zot.service` after rendering the new + htpasswd file. 4. Replace the CI secret and verify login before removing old runner state. -## Storage maintenance - -The daily garbage-collection unit stops Distribution before its mark-and-sweep -pass, then starts it again. This prevents concurrent uploads from being mistaken -for unreachable data. Garbage collection does not delete tagged images. +## Storage -R2 credentials must remain Object Read & Write scoped to only the -`container-registry` bucket. Large blob pulls receive signed R2 redirects; -manifest and token traffic continues through eta. +Blobs, the dedupe index and metaDB live in `/data/zot`. Repeated blobs are +hardlinked, so each layer is stored once. The store is not backed up; if it is +lost, let CI push all images again. diff --git a/modules/container-registry/auth.nix b/modules/container-registry/auth.nix deleted file mode 100644 index 21cb062a..00000000 --- a/modules/container-registry/auth.nix +++ /dev/null @@ -1,123 +0,0 @@ -{ - config, - containerRegistry, - lib, - pkgs, - self, - ... -}: -let - dockerAuthPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.docker-auth; - - authConfig = { - server = { - addr = "${containerRegistry.auth.address}:${toString containerRegistry.auth.port}"; - real_ip_header = "X-Forwarded-For"; - real_ip_pos = -1; - }; - - token = { - inherit (containerRegistry) issuer; - expiration = 900; - disable_legacy_key_id = true; - certificate = toString containerRegistry.signingCertificate; - key = config.sops.secrets.container-registry-token-signing-key.path; - }; - - users = { - "" = { }; - "docker-push-bot".password = - config.sops.placeholder."container-registry-docker-push-bot-password-hash"; - "registry-admin".password = config.sops.placeholder."container-registry-admin-password-hash"; - }; - - acl = [ - { - match.account = "docker-push-bot"; - actions = [ - "pull" - "push" - ]; - comment = "CI may publish images but cannot delete them."; - } - { - match.account = "registry-admin"; - actions = [ "*" ]; - comment = "Human administrators may manage image retention and inspect the catalog."; - } - { - match = { }; - actions = [ "pull" ]; - comment = "Images are publicly readable."; - } - ]; - }; -in -{ - users.groups.docker-auth = { }; - users.users.docker-auth = { - isSystemUser = true; - group = "docker-auth"; - }; - - sops.secrets = { - container-registry-token-signing-key = { - sopsFile = containerRegistry.secretsFile; - owner = "docker-auth"; - restartUnits = [ "docker-auth.service" ]; - }; - container-registry-docker-push-bot-password-hash = { - sopsFile = containerRegistry.secretsFile; - owner = "docker-auth"; - restartUnits = [ "docker-auth.service" ]; - }; - container-registry-admin-password-hash = { - sopsFile = containerRegistry.secretsFile; - owner = "docker-auth"; - restartUnits = [ "docker-auth.service" ]; - }; - }; - - sops.templates.docker-auth-config = { - owner = "docker-auth"; - content = builtins.toJSON authConfig; - restartUnits = [ "docker-auth.service" ]; - }; - - systemd.services.docker-auth = { - description = "Docker Registry token authentication server"; - wantedBy = [ "multi-user.target" ]; - after = [ "sops-install-secrets.service" ]; - - serviceConfig = { - ExecStart = "${lib.getExe dockerAuthPackage} ${config.sops.templates.docker-auth-config.path}"; - User = "docker-auth"; - Group = "docker-auth"; - Restart = "on-failure"; - RestartSec = "5s"; - - CapabilityBoundingSet = ""; - LockPersonality = true; - NoNewPrivileges = true; - PrivateDevices = true; - PrivateTmp = true; - ProtectClock = true; - ProtectControlGroups = true; - ProtectHome = true; - ProtectHostname = true; - ProtectKernelLogs = true; - ProtectKernelModules = true; - ProtectKernelTunables = true; - ProtectSystem = "strict"; - RestrictAddressFamilies = [ - "AF_INET" - "AF_INET6" - "AF_UNIX" - ]; - RestrictNamespaces = true; - RestrictRealtime = true; - SystemCallArchitectures = "native"; - UMask = "0077"; - }; - }; -} diff --git a/modules/container-registry/default.nix b/modules/container-registry/default.nix index 46fcc652..2a7a4465 100644 --- a/modules/container-registry/default.nix +++ b/modules/container-registry/default.nix @@ -1,159 +1,162 @@ { config, - lib, ... }: let - containerRegistry = { - domain = "registry.sjanglab.org"; - issuer = "sjanglab-container-registry"; - signingCertificate = ./token-signing.crt; - secretsFile = ./secrets.yaml; - - registry = { - address = "127.0.0.1"; - port = 5000; - }; - auth = { - address = "127.0.0.1"; - port = 5001; - }; - }; - - r2Endpoint = "0572d3fa726276fa78f433d5ba90048e.r2.cloudflarestorage.com"; - bucket = "container-registry"; + domain = "registry.sjanglab.org"; + secretsFile = ./secrets.yaml; + pushBot = "docker-push-bot"; + admin = "registry-admin"; + credential = name: "/run/credentials/zot.service/${name}"; + # CI overwrites the -cache:buildkit tag on every build. + pushActions = [ + "read" + "create" + "update" + ]; in { imports = [ - ./auth.nix - ./edge-proxy.nix + ../zot + ./reverse-proxy.nix ]; - _module.args = { inherit containerRegistry; }; - - services.dockerRegistry = { + services.zot = { enable = true; - listenAddress = containerRegistry.registry.address; - port = containerRegistry.registry.port; - storagePath = null; - enableDelete = true; - enableGarbageCollect = true; - garbageCollectDates = "*-*-* 04:30:00"; - - extraConfig = { + credentials = { + htpasswd = config.sops.templates.zot-htpasswd.path; + "oidc.json" = config.sops.templates.zot-oidc.path; + "session-keys.json" = config.sops.templates.zot-session-keys.path; + }; + settings = { + distSpecVersion = "1.1.1"; storage = { - s3 = { - region = "auto"; - regionendpoint = "https://${r2Endpoint}"; - inherit bucket; - rootdirectory = "/registry"; - encrypt = false; - secure = true; - v4auth = true; - chunksize = 5242880; - multipartcopychunksize = 33554432; - multipartcopymaxconcurrency = 10; - multipartcopythresholdsize = 33554432; + # Blobs, the dedupe index and metaDB live on the HDD array; images are + # reproducible, so the store is not backed up. + rootDirectory = "/data/zot"; + dedupe = true; + gc = true; + }; + http = { + address = "127.0.0.1"; + port = "5000"; + realm = domain; + # OIDC redirect URIs are built from this rather than the proxied Host. + externalUrl = "https://${domain}"; + # The 60s defaults cover a whole request body, so a single-PATCH + # multi-GB layer push fails; match nginx's 1800s proxy timeouts. + readTimeout = "30m"; + writeTimeout = "30m"; + # Accept Docker media types so default docker/skopeo pushes and Docker + # Hub mirrors work; zot stores them as-is without converting to OCI. + compat = [ "docker2s2" ]; + auth = { + htpasswd.path = credential "htpasswd"; + # Every registry request carries Basic credentials, so nginx cannot + # rate-limit login attempts separately; slow failures down instead. + failDelay = 5; + # Lab members sign in to the UI through authentik; docker and skopeo + # cannot run OIDC, so they log in with an API key created in the UI. + # A key keeps the groups from its owner's last UI login, so removing + # someone in authentik does not revoke keys they already hold. + openid.providers.oidc = { + name = "authentik"; + issuer = "https://auth.sjanglab.org/application/o/registry/"; + credentialsFile = credential "oidc.json"; + scopes = [ + "openid" + "profile" + "email" + "groups" + ]; + claimMapping.username = "preferred_username"; + }; + apikey = true; + # Fixed keys keep UI sessions valid across zot restarts. + sessionKeysFile = credential "session-keys.json"; + }; + # Only research/ and infra/ exist for non-admins; any other repository + # matches no pattern and is denied. zot applies the single longest + # matching pattern, so a more specific pattern must restate every + # policy it should keep. + accessControl = { + repositories = { + "research/**".policies = [ + { + users = [ pushBot ]; + actions = pushActions; + } + { + # Without update a published tag cannot be overwritten. + groups = [ "sjanglab-researchers" ]; + actions = [ + "read" + "create" + ]; + } + { + groups = [ "sjanglab-students" ]; + actions = [ "read" ]; + } + ]; + "infra/**".policies = [ + { + users = [ pushBot ]; + actions = pushActions; + } + ]; + }; + adminPolicy = { + users = [ admin ]; + groups = [ "sjanglab-admins" ]; + actions = pushActions ++ [ "delete" ]; + }; }; - redirect.disable = false; }; - - auth.token = { - realm = "https://${containerRegistry.domain}/auth"; - service = containerRegistry.domain; - inherit (containerRegistry) issuer; - rootcertbundle = toString containerRegistry.signingCertificate; + extensions = { + search = { + enable = true; + # Trivy scans blobs from local storage. Each update pulls trivy-db and + # the much larger trivy-java-db from ghcr.io, which zot cannot skip, + # so refresh daily instead of every 2h. + cve.updateInterval = "24h"; + }; + ui.enable = true; }; + log.level = "info"; }; }; - systemd.services.docker-registry = { - after = [ "docker-auth.service" ]; - wants = [ "docker-auth.service" ]; - environment.OTEL_TRACES_EXPORTER = "none"; - serviceConfig = { - EnvironmentFile = config.sops.templates.container-registry-env.path; - DynamicUser = false; - StateDirectory = "docker-registry"; - WorkingDirectory = lib.mkForce "/var/lib/docker-registry"; - Restart = "on-failure"; - RestartSec = "5s"; - - CapabilityBoundingSet = ""; - LockPersonality = true; - NoNewPrivileges = true; - PrivateDevices = true; - PrivateTmp = true; - ProtectClock = true; - ProtectControlGroups = true; - ProtectHome = true; - ProtectHostname = true; - ProtectKernelLogs = true; - ProtectKernelModules = true; - ProtectKernelTunables = true; - ProtectSystem = "strict"; - RestrictAddressFamilies = [ - "AF_INET" - "AF_INET6" - "AF_UNIX" - ]; - RestrictNamespaces = true; - RestrictRealtime = true; - SystemCallArchitectures = "native"; - UMask = "0077"; - }; + sops.secrets = { + container-registry-docker-push-bot-password-hash.sopsFile = secretsFile; + container-registry-admin-password-hash.sopsFile = secretsFile; + container-registry-oidc-client-secret.sopsFile = secretsFile; + container-registry-session-hash-key.sopsFile = secretsFile; + container-registry-session-encrypt-key.sopsFile = secretsFile; }; - # Distribution requires an offline registry during garbage collection to - # avoid deleting layers uploaded while the mark phase is running. - systemd.services.docker-registry-garbage-collect = { - after = [ "docker-registry.service" ]; - environment.OTEL_TRACES_EXPORTER = "none"; - serviceConfig = { - EnvironmentFile = config.sops.templates.container-registry-env.path; - TimeoutStartSec = "6h"; - }; - script = lib.mkForce '' - trap '${config.systemd.package}/bin/systemctl start docker-registry.service || true' EXIT - ${config.systemd.package}/bin/systemctl stop docker-registry.service - ${config.services.dockerRegistry.package}/bin/registry garbage-collect --delete-untagged ${config.services.dockerRegistry.configFile} + # LoadCredential copies the file at start, so account changes need a restart. + sops.templates.zot-htpasswd = { + restartUnits = [ "zot.service" ]; + content = '' + ${pushBot}:${config.sops.placeholder."container-registry-docker-push-bot-password-hash"} + ${admin}:${config.sops.placeholder."container-registry-admin-password-hash"} ''; }; - gatusCheck.pull = [ - { - name = "Container Registry"; - url = "https://${containerRegistry.domain}/v2/"; - group = "storage"; - conditions = [ - "[STATUS] == 401" - "[CERTIFICATE_EXPIRATION] > 720h" - ]; - } - ]; - - sops.secrets = { - container-registry-r2-access-key-id = { - sopsFile = containerRegistry.secretsFile; - owner = "docker-registry"; - }; - container-registry-r2-secret-access-key = { - sopsFile = containerRegistry.secretsFile; - owner = "docker-registry"; - }; - container-registry-http-secret = { - sopsFile = containerRegistry.secretsFile; - owner = "docker-registry"; + sops.templates.zot-oidc = { + restartUnits = [ "zot.service" ]; + content = builtins.toJSON { + clientid = "registry"; + clientsecret = config.sops.placeholder."container-registry-oidc-client-secret"; }; }; - sops.templates.container-registry-env = { - owner = "docker-registry"; - restartUnits = [ "docker-registry.service" ]; - content = '' - REGISTRY_STORAGE_S3_ACCESSKEY=${config.sops.placeholder."container-registry-r2-access-key-id"} - REGISTRY_STORAGE_S3_SECRETKEY=${config.sops.placeholder."container-registry-r2-secret-access-key"} - REGISTRY_HTTP_SECRET=${config.sops.placeholder."container-registry-http-secret"} - ''; + sops.templates.zot-session-keys = { + restartUnits = [ "zot.service" ]; + content = builtins.toJSON { + hashKey = config.sops.placeholder."container-registry-session-hash-key"; + encryptKey = config.sops.placeholder."container-registry-session-encrypt-key"; + }; }; } diff --git a/modules/container-registry/edge-proxy.nix b/modules/container-registry/edge-proxy.nix index 3328b4d8..36c10595 100644 --- a/modules/container-registry/edge-proxy.nix +++ b/modules/container-registry/edge-proxy.nix @@ -1,68 +1,37 @@ -# Public Docker/OCI Registry and token-auth endpoint on eta. -{ - containerRegistry, - ... -}: +# Public ingress for the zot registry and web UI, which run on psi. +{ config, ... }: let + inherit (config.networking.sbee) hosts; + domain = "registry.sjanglab.org"; nginxStatusPort = 9114; - - proxyHeaders = '' - proxy_http_version 1.1; - proxy_set_header Connection ""; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_connect_timeout 5s; - ''; - - authLocation = { - proxyPass = "http://${containerRegistry.auth.address}:${toString containerRegistry.auth.port}"; - extraConfig = '' - client_max_body_size 64k; - client_body_timeout 10s; - send_timeout 30s; - proxy_send_timeout 30s; - proxy_read_timeout 30s; - - limit_req zone=container_registry_auth burst=40 nodelay; - limit_req zone=container_registry_basic_auth burst=10 nodelay; - limit_req_status 429; - limit_conn container_registry_per_ip 10; - limit_conn_status 429; - - limit_except GET POST { - deny all; - } - - ${proxyHeaders} - ''; - }; in { - services.sbee.nginx.edgeVhosts.${containerRegistry.domain} = { }; imports = [ + ../acme/sync.nix + ../gatus/check.nix ../monitoring/audit/nginx-access-logs.nix ]; - services.sbee.nginxAccessLogs.services.${containerRegistry.domain} = "container-registry"; + services.sbee.nginx.edgeVhosts.${domain} = { }; + services.sbee.nginxAccessLogs.services.${domain} = "container-registry"; + + acmeSyncer.mkSender = [ + { + inherit domain; + serviceName = "acme-sync-registry-to-psi"; + remoteUser = "acme-sync-registry"; + remoteHost = hosts.psi.wg-admin; + } + ]; services.nginx = { recommendedTlsSettings = true; commonHttpConfig = '' - map $http_authorization $container_registry_basic_auth_client { - default ""; - ~*^Basic\s+ $binary_remote_addr; - } - - limit_req_zone $binary_remote_addr zone=container_registry_auth:10m rate=20r/s; - limit_req_zone $container_registry_basic_auth_client zone=container_registry_basic_auth:10m rate=1r/s; limit_conn_zone $binary_remote_addr zone=container_registry_per_ip:10m; ''; - virtualHosts.${containerRegistry.domain} = { - + virtualHosts.${domain} = { extraConfig = '' add_header Strict-Transport-Security "max-age=31536000" always; access_log /var/log/nginx/access-audit/container-registry.log nginx_access_json; @@ -71,11 +40,13 @@ in limit_conn_status 429; ''; - locations."= /v2".return = "308 /v2/"; - - locations."/v2/" = { - proxyPass = "http://${containerRegistry.registry.address}:${toString containerRegistry.registry.port}"; + # zot serves the registry API, its own /zot API and the UI from one origin. + locations."/" = { + proxyPass = "https://${hosts.psi.wg-admin}"; extraConfig = '' + proxy_ssl_server_name on; + proxy_ssl_name ${domain}; + client_max_body_size 50g; client_body_timeout 300s; send_timeout 300s; @@ -84,14 +55,15 @@ in proxy_request_buffering off; proxy_buffering off; - ${proxyHeaders} + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 5s; ''; }; - - # docker_auth exposes both Distribution and OAuth-compatible token paths. - locations."= /auth" = authLocation; - locations."= /auth/token" = authLocation; - locations."/".return = "404"; }; virtualHosts.nginx-status = { @@ -116,4 +88,16 @@ in scrapeUri = "http://127.0.0.1:${toString nginxStatusPort}/nginx_status"; }; + gatusCheck.pull = [ + { + name = "Container Registry"; + url = "https://${domain}/v2/"; + group = "storage"; + # Every repository requires a login, so a healthy registry challenges. + conditions = [ + "[STATUS] == 401" + "[CERTIFICATE_EXPIRATION] > 720h" + ]; + } + ]; } diff --git a/modules/container-registry/reverse-proxy.nix b/modules/container-registry/reverse-proxy.nix new file mode 100644 index 00000000..29cf073e --- /dev/null +++ b/modules/container-registry/reverse-proxy.nix @@ -0,0 +1,48 @@ +# TLS endpoint for zot on psi. eta provides public ingress over wg-admin; local +# clients reach it directly so pulls on psi never leave the host. +{ config, ... }: +let + inherit (config.networking.sbee) currentHost; + domain = "registry.sjanglab.org"; +in +{ + imports = [ ../acme/sync.nix ]; + + acmeSyncer.mkReceiver = [ + { + inherit domain; + user = "acme-sync-registry"; + } + ]; + + # k3s, docker and skopeo on psi resolve the public name to this vhost. + networking.hosts.${currentHost.wg-admin} = [ domain ]; + + services.nginx = { + enable = true; + virtualHosts.${domain} = { + forceSSL = true; + sslCertificate = "/var/lib/acme/${domain}/fullchain.pem"; + sslCertificateKey = "/var/lib/acme/${domain}/key.pem"; + locations."/" = { + proxyPass = "http://127.0.0.1:5000"; + extraConfig = '' + client_max_body_size 50g; + client_body_timeout 300s; + send_timeout 300s; + proxy_send_timeout 1800s; + proxy_read_timeout 1800s; + proxy_request_buffering off; + proxy_buffering off; + + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + ''; + }; + }; + }; + + networking.firewall.interfaces.wg-admin.allowedTCPPorts = [ 443 ]; +} diff --git a/modules/container-registry/secrets.yaml b/modules/container-registry/secrets.yaml index 75246427..d094a96e 100644 --- a/modules/container-registry/secrets.yaml +++ b/modules/container-registry/secrets.yaml @@ -1,41 +1,40 @@ -container-registry-r2-access-key-id: ENC[AES256_GCM,data:03IkRlTT3I3NT2U6A3mHeU5L6+sGs1dk3PzWXiG4X4g=,iv:0S9YQSLa1vrcHkhKpDIdLhLS9Hzu1Pwi2JZOAGHsb3o=,tag:1Xq7xLQUMACEpXXElIY73A==,type:str] -container-registry-r2-secret-access-key: ENC[AES256_GCM,data:F8TDXjdFzeyfKHn3P+hw/BQ4gFJU6eDVKqF9gGsWGGjEmFndKpoR896e2QsG+vicCJlITDfxVBaVnNSI/TwJpQ==,iv:glDePLdG8Hpsw9hhzOptLBFpYeacNDSa41jsOafSWTU=,tag:Z4DKjRlDVk55zKqsJfT5yA==,type:str] -container-registry-token-signing-key: ENC[AES256_GCM,data:ek+etcZat5jw3QKcgmZFerNoNzySPYVTZDAMB6UmjHYUcY6UREffwy+vjj4PyvlqgIK7YQYJl7VM732yo6feRYjqeU1N3XP/A/rXM4BdfKlF414vNOIArFpusPjGGrF5QcwpdkKn5B2GRdCr2UghCH+kEko/BdvBbU0QErLySZVsHizrCXAWHvdmyI+muQQWJwIJwrL7Agd6rdvXypqGVVy+NgOJg+uyyidlAeb2Qh12YuTjn8rEOOPeBCi5m6OsrJZLytBihhcyLPNmw8nbHTq2Jg97J22OgYUz1QJ19mPOqxrCR1eG+9SkodeGrgbt0/2a4kYe1GMIiadql27MWBZUcy8Wo+a65syeEzO8gjmG0q0y3lRCLwqo3+2omNT/g3nRZGla7y4hneZjzEDx+T4/uzUnQFouPb1RHY3LVd5FppbEtCLppWzgaY3CiB3w3CULO8kXHDuilWLnTbvPFeGEYvrY6YEYbevS+VJRHR1WX/Bf//UovJsrE/5qyJc31QQ/A8hBnNpGayKlEhz2uV/iLt4MZ4JmqymwnZxUkiTZbCaDLCOODqzwnxMT7c+C+z65PvHNBiNGq6QuW1d9hCTunO3yHKbVy45/YDvLmvHIEOl07ozejOR3Tz8IUNtg8nS/Z5CmhlN9fhOY2HC5jv/CWiwFFHNJESCs0La1FxFCYWL+5i1IPZlPk+o4py8BaX0nacx5RIjihYzAXXlmFdluZhBmRiYdoPH7WXNd5FwfvzMUaGKGTPFZaxUtl8Yl9RuOHaINQ2ybpeFMzWXuiRWdIAWyZrYiS0utjGOGzlAJxbFZyQqqhATh/+QQT/FLRGfvkPYqrEmX1uFtKi+sIDlQgD3XtLjRL0bNWrEgRZBhk0BrKM6IQm3/yVoCaU1Ub54+U8X8DrG1kGnVvnh+CB4zSR7UF2hyuatHxssDZ7V7G0VIXdaaj+T6cCL1GMjhj/HPDWoQn7aPyXbwbK0PuARrvDaS+J9srbWusTyUrd5TjNjMywPMNbMdm6yTWvEPntEjShDFI+8vkyayTebpQBbtX3pQdbK+laRk5Jp8wQONF2Ydj8kjH42iIcsUXFuop/PoJkfzS/U/aQfzcdDmm6LCtnv9jd14eIqirNvmUFTiy6ACvcd1hyENpJoMZ4dt4yelK7zy78zu+ZQEaLheAZ1voE+9HQ4Z5BaxeQIeuaYY7v3DIy4+bDMcBlI+rKkNzKlpi2N985HO8JXMM8iT/Boo/1nF6+oAuHoXEnbj8QwOJBby20TIKLkxkuNIl3Z8bMDa+3uFumo0L9WgJn66KFvckIeXkmHYAvmwSu623YNX6OgkKJi663Zcm/gg6GvHglB01/aXh5mzUBKHj2qPxzBrmDB5Z1/M0v1s5YM3wmRGxUTRETbNPSuGkIl+5rQaJFpbYB9QgJRKLMuDeOyN5S4cy5wfmVgUiq4tsUNV+fLs94Z5bIgg4X3/FxGwGTYiTzY+lmLy2wAXujp4MCF6J9xOu4I1AWJHWdHAC9dRTfCw373bb1aUzgeybwbyi4CNTNZwW7DCW+3dLxuiHLz2tz+b5jARzj+ThWEuuGgOIok4Vjr3z1spIy/f57kk1vQ/yoSzHXNHgGVbVujwip1W/I81HX2nY+hqK1EDw/38dzA9MgagRyhhZdWjsnaMUFJHT77iK4jgHx+movTda4Dfd+bjxzRkJ+3Tgfwho4TTXMfP87nP062Uk788sTnT8SrVkNO+t6bwW2NFQv2hrg3l65ppVVBeeltLj8moHOOG98IEbfa2WnEn38aF3thXh7g4HDgNvYF1O8JZODA0VVuUoJ9pPARC/91iUHuwV2n3mS7QQRN/XDidh9qcVg8dYi6zzWHDLDaZ8i0SsQQx7VC6g4fgX+T7tIhdsLMaIpAzKCYZ9UDdflsq71k9ctaEIJ9hkMvFN6k4vOBurwACWnpidhnUnryKQ9GZLCdfJqrwFgQnuuyr4K3w9gVn8j9hmo0jW3HODlMo0a5Q92cT9jkWv2PCLBFeX7jfQcreL6LcIJZrtogOwLs0Trs3S8LIPiNWoPU1OVjcR3aN/3Qw1Ufkve+tDbI7ky0pX62gWr8o92a1qoncYp/E8BIHOkRRB0qUOD7jn6rRhfIfUXfvkpuUTEEBSLoBugjePIhQwQnPu0bVBudPbDQed4/cOEJa/58TJy6+cLsjiO3PyGWl4Z16/fIx7BjuEF8aWHngAyXP54ueW3cjWk/5XFPJCCTr+1YDrFn00LyyV1c7HIvMMqC3zKiZKwkCLe+G17CAPe2kgOaPAOsFE1o1F4OfdhqaGcz/0OZ+9PfbbCVa/QSdWaP2/FClPDaI7EGLglVRC3pcAM+Ox73UyXkh2ZDR27xxHFD1FYSWWBe5t1lL5u54/h942eK9kntNFJId+CSZN2lteDfOUe6rtBUwI2NVM6gF8IFKOrBR/V2wIdotojVEu0gZOk4L2D2TasMnL+MG0EDVvYXBPIxbm46tlHC5oPuX1LAy0hyH7LEDYIIbw/J5MtXqU0qXUqpfyKo6YfB5qi3UtGU+BRjNUn8yCM8lUx4+jRNWKbwu+zBGhatm0Gu67nVcrZ/hsy5tWz0pWLfTola08RdwTCFKNqlgUx2Iw2S4P1Uk2aMWarAoP6edRrYz/+Ln+FPeo0tqcvhK4Bk+tjGc/BDlg/1JtIa8GFDb2OVfM9WpsU3cfVSfo9k01rVADiL22jLsiv0R5xp22Fb/qjh0lgkC0biYk2kIlkeNx3Wfn2CL40K8dafQMu5xSzmiStF5/Qzvr03w9tWCe07SF3YNM5apODXxLXXO7m8pTigT/6k0qrVTp6wvUGUUh4erFQ6rs6MA/rlDrf3rr8txaaLw01TmAsHkQTeTFNv/UYHMpESAHVUVdfatlrQUaZspOYgb2VkrraSTDOCssUTmi5bMEIxY66bztNpra24GOumtRRcvoIgAtn/2wJaBrbfE1sLcEU0HiKUKcE/UdS3ZZe00p4Qhrzqe9V5IVzJ2dpxK+8fkZJqCRMGxRyjUheowc3+s/0Z12aVngbIJI8UTo2E8VblyCMUUo5oXVL1R+t72hOj1SxoOZ72BMKPFo93Bf6cCjLzu+QwhJqVZI8HArjUj1EBJ9aEPruQoZQjWji8k6mX2Hzs7BypvBSkykboir18IE6G3F19sZqpnqgDUhiXHpvNVaAj0kIM6PdK8CoB14uRQjqAcVaDMzJ4xU7WiuW543gBBAWn26EbojtVDIMY07usLWpPn3zNKX6nf+fpzc/+vSH4W0j/w/5lMz8hylrUikVcg/WCvLr8uiAHeCvKSRf3ZweJCxvbJgfrnNEyn8mfhRP3CKrAMviiJElDZxlWMihhy5SXdT7p/s19sU5cIN4kh9hLI4C1CF5dy3Kh4gd5bNhxAjmXyxbnXTPEmZL6ZPYvbDR/132l3dL5dJTSHVMgWfOiL/5ieBSCCJqWQVxhOS+InTz6gZpzsrXDI+yGT0cJ8VjbKClylvmgQnqxp7Za12kvwgC1lxG8UfK79Xo6ip7TsBdWEae3gcXX5Rkmwca0TNhE05qLNJ16Sk24o1ituq3ggSv2s53s8yhGrVHIA2G2saWT/9sKHsOjM7Oj6JTkSQj2rYK8KdT4Rg2ZiuH6TUSYkN2pMz5OME+gmrZ0nCQhwpQ8TvZ7lKG/sIJ+5gRGY8Zdy58iS7flnn06ERuMja8U0Z7oeyw0ByQrnVHgRpFZMAom070ZTrg/hYjOlWSVPze9mueiiVQ0gQkvDxkFHXTVd+/mgUS4kp81vfIbGG0uTRCJHUFhD/lxKIJAGRH3/JvIY0GIDTry9cFb2cmVdpfUQq3Zz11XPoU9dWeC2mUHAx+t99Ii9+wr0l/jxoSCAHZyi43/edo8OeuLo7Ms9TPS6hQRMSGU0gxGntu3x2j1foMIaAHFoArop2DaE1hxriYqoMjgNrH41UgPuAt3bK7pQJ8cB67rU48jpaEP9G5GQSEXGeItP8N/CGydbaRCW9OXyfzCJJIVq7X4nU5zfVvfiqoArRLZe/0f8PdJBCzKdtNAZD65GyeU2osj/7yqG9noArm2+8b2N/aM5e2e/uDRAnVWMULb4gBnexpdZ/w1Lo2QMVCxAJszklPdxf2skX7VhiGbTXO9jgtlllIgB4hMalo8kWmJplqt9kX1ak6ZFabe0KAm4g42dqPT9Fdi/4DHbMLv/VQ7NYzI1BsnMjlxI1Bkm7EWM1Hfh/gjmglG2bz4x/OuMgz3XDbxEQgZP0srUctqwqQD0vPemCJYm1J2/nap5Cy3sagBy8WxfixMWVcv+K39+6w8hgRX90wMBuy+ZAzBzL56fMHsGXhPRp9RFzTL8xVz1/ji9D+V57MHzKl8kZ7U=,iv:Jg5/yuRqpx9L083qnNSJfijPQIi06+0mv/tnlnqKfIs=,tag:dtgqSmB36zqbaJzMEq1Njg==,type:str] container-registry-docker-push-bot-password: ENC[AES256_GCM,data:7/ySaGojQnVu3VFoNl4C3Jb/sxlqavkPegX3Lr0mI+K+JftGmt2US/qaExvD80fvsMqk+oPFEfPprsIhqB8vtQ==,iv:AQvk3Uq3C+7boJD89dWYrmJf/O0CDOmbt1T4EvvLCsk=,tag:p63ZlXbGwWsJOIsxe4DFZA==,type:str] container-registry-docker-push-bot-password-hash: ENC[AES256_GCM,data:1C8XEH4QdRZkL6HgDnYNP8YEDL+2aOP6Q3tXAcnNtHy3E4fnu0KcbB+tR7FHAF/ROQ3tgVmmmprzGZ5R9rU=,iv:L4yed0e6GgM4s+IHcaBVKcstY3cHyYIUFp4r/IMFVuw=,tag:Uyy1egZrCCavPJMlq8NgAQ==,type:str] -container-registry-http-secret: ENC[AES256_GCM,data:ENDy8I3iKQQYr34+NWOQTCl8zBFGDs86OGxhSCA/PI/4w+faaYLW/tEoMtmeIZzDuBxw37NcqthCPf0i2lUo8Q==,iv:KD8M1z/M5R6w5PZfzQR5np2IZRQ3vIrjDwtu0JKnfco=,tag:WtS/PP2pR97sdC8wt3Y+ow==,type:str] container-registry-admin-password: ENC[AES256_GCM,data:48CB91BSfFsoy9QhTTu33Je2XtOEVty4VsHe37rte4/Z5CtvFOvr4BOcxLJE1Wn92FVAN7amJ57eC6zhocA2Ag==,iv:DZ2aWH/dsCssMT/C8b6YzIH3XIT+QVPJUthA1ci5sEg=,tag:CMoeFjLe45+9vG+EV2A6Mg==,type:str] container-registry-admin-password-hash: ENC[AES256_GCM,data:uIvEIBw7I9f08guZfsvfTQy9X+hBhk5nchk8tARVgtTXEMUShSWf1ut/NsYfy8NJvaVFDoIa7ke92RnT,iv:KYmgrG4e5bhhDxRJ1jEfV/sYDmVBmiOiHoGp/ieg1Sc=,tag:Dnf+9CXPcCpFo8Y3BiOrrQ==,type:str] +container-registry-oidc-client-secret: ENC[AES256_GCM,data:9XJRV2SLcQ6pw9NJ7M26/M16fNLhbs2awdywfolB+fxCWaoIxI6j79YWdU4DBVDGpSKOiNv51p3EMApI7laN9w==,iv:qolqTZEKajE6DoDTKu1fMAfxHUHxq2sPTM398Emao70=,tag:okjZetgjB3bpsbVj1O8Z3Q==,type:str] +container-registry-session-hash-key: ENC[AES256_GCM,data:uePxmztwyqdZ9iNzChZkmniTW21TElkv1CA70n36hs+dtGii7RwNZcWbhkYTzON48bRMw23Kjuaea2/R7+9juA==,iv:5qxk5u0Pn+SGwIaChOzVNCu3kYA0APHw+sHGmAYXh0w=,tag:qy55zqh1h+1vvYsLKyW1kQ==,type:str] +container-registry-session-encrypt-key: ENC[AES256_GCM,data:37VHl7uiOWmHxnX5BwvgMCX6Agi7U5QwOiNBAeUaWoc=,iv:BHrAah8kKv9dWvQWGDFeZTO5iNwnZcdffQIYE19FrBE=,tag:fMWLRQ8xPO6YI8/ZkT4Y2g==,type:str] sops: age: - enc: | -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSSXRSZHJ1dGduaktpR25v - WmMrNGpFZzM4Uit1UXJNV0FyaVBuVkYyTFZRCk5xd05USDdpd3QzZkVRMzhtcWxt - RXFrTlpuaEdoWjgrbjAvaWJNRkdPYzQKLS0tIHdMRGdpd1cvZC9rU3o0Ni9EcWV0 - Z2ZiNjBxMW1MU2k1VmZ5ZSthSlVTdFUKOqyIHcR2vMP5kQbw/sQgkc/kQyJGl8Jm - YOmWvH/EdE3aGmnkw17quToza69/CCi//CaefZlEQrlDsLKZRUlbaw== + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBYaXUxU3pqYWh3S0k0R1Qx + eXYyVHNFSXpOK1dmL0pqQTRsNnRXUXFPMXlnCkpwM1BzYXRmSzJPWjZ1N3pEbE5G + cHhFN3NjR2pwYnlnbmtHK2twdTRPaW8KLS0tIGVtYkFFdHZ3c3Mwc0MwQllRd0lE + WXg3dmFsQitUR0xaQkdBVElTeFZ6VHMKs0d0G70zdfJ2/cAluWC79V6e1k06bhe/ + /jgHkw1+LijTWCcasAISHmYn6y2cQix5BkUs0O7V7NkY6c+DRbCfrw== -----END AGE ENCRYPTED FILE----- - recipient: age1zdhqm6ptcnuu3tf2lzcngqmf6eud7jfah7v8falfy5mdksmnfuzq35sq54 + recipient: age18yuzugm0f2l7cc4sdmp5f442af2w897x9l9syeaef3yx3jzc5pjs9zez0f - enc: | -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA0aDlkb21OUmQyUDRxZ1h3 - eDdraDZSZjY4N243SzlNSXdzQ05OOGFoQnhJCkV0aDFGaEJWYnJUKzljbmp1Y0R0 - b3dlMVZyMjBrUUlIcFp5enFqSUI0UlkKLS0tIDA4Y1BpU2tvYVhsUkRTVjNReStN - NzNRNHVGSXNmd1k4SGUzWkJmUVFlSnMKQLV5deifcJraYsV4QxpJxYCx9+Fi3a+i - ygzP2Xz2PSZAUxbOdtBYllOxaxFuQ07lRxopBv3cftkFKmCLJxb9aQ== + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAxUG94VmwwUDdzVmlKd3Ix + bnNQRzNKWlJzWDZCeC9nUzg2cExuaFF6dUFFCjZVR1F5RWdBRGlJTVhLWHZ4RzAr + R0FFUVBvUUt2NEs0OGJLZ2h4dkx5UUEKLS0tIGlHdjBMelUxZWFNT0pyTm1uZnZZ + L2hKZWIyQ2xxM29XZFNNUHB6VUFTN00Kwr5S/RCZdviccngSTgglK0NIYvppBKcV + OfSSQ1gnarJ+vWFANAQvutyBi+sOA7qfBoBNr1GD9BFOSthc4PCq1Q== -----END AGE ENCRYPTED FILE----- recipient: age1730f3cxdyh56zw8xcvlmpa7u2x7353wu4u0e58kyx24rsefgp98sxehm6s - enc: | -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2Z0g3ZFpjbXRBZzNkcjQr - dDlMeTA2a1d0a0k3eGtVSW5RdmFqbWVyTVN3CmJjRmdBbjg4WUIwWG4xT1k1QVA5 - WjFzcytmLzgzYnQ1cU93STFTd3hYYUEKLS0tIGxpaHVXUDRNSTlZUmhqSTRvRU5D - TDlqVXdmOXRHTUV1R05oSUE1aDd5MGcKpc6dkmggQayqHudb0MHqVMfS8Cy9GgrV - DxnTHzVleQ4Jqp0vKp5GVTpwb0rqv+ZMt2dbGSfpPeIfdnqttqTcag== + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGM2ZWTEpHenoydTVYWXV1 + UUI2WHJPU0E2UWxRRmczcHcvVGRpZFBYRUFZClhpYitsOUp2ZDBZa3oyUE9WZldG + WG1lL0FrQnZIaFdEM0M2dUw3SHlSNU0KLS0tIGhJNU1wb0VyeGo3ejZ1QzRoZnZr + aXhLb0gvQ1AxeTRKS0tvUlhWUlBrQW8KUK4oystZVApxlmPEKKchRh7AeJij7UYj + WMEzSUP1otoTpbWy4KA3Xadijbo63EENuyv2RHONeCgbqi3UeeI1QQ== -----END AGE ENCRYPTED FILE----- recipient: age15h90xndr3kn5d8et69y5hf54lclmvafhk5a0d3hgc0a9fvjexcaq0t9hvr - lastmodified: "2026-08-02T16:17:34Z" - mac: ENC[AES256_GCM,data:+l5kLNXlPFGo3i8Z3BuZSbF+MNLAEbinQ221xm0o6Nj9jFqqxkfK0N5qh/PGzSFC7pv8CjTUiyGAk4mR6X0btvalTaCFa+GrMU2+gXHkFpLfjs/pOhqfyZ/9bKM1J7hgQ6AnduQ/MD3QCPm8w8Gcs4ZnTfVc4ZBfllQKMTymHAg=,iv:J12uO6afWlb2Z4Pdrmd+i1nylhMm1/IhzSfobu7Z0Fg=,tag:q2Sco0FFQeL2AY/CI5faOA==,type:str] + lastmodified: "2026-11-13T04:38:56Z" + mac: ENC[AES256_GCM,data:oPKvgiqTXdqY9z3CWI40z3Cs3vKCQyQyh41cDehJlVSMg+83BLG960bEhwLcEXR59taKIOckBkvN0y58iRV/RO1JA9S2cms4tHUXcmI7v60aT/NMeqfL1zS7xHnzftMSvZVXUSODF4Lzirb5/Za/5z8t0IBKRUwftpufwHcXp1c=,iv:cjKLwM3wV/duX8TYnTr6ovpib2u93TcYeBY/afXZ8tk=,tag:pkyXCtZzx+izVY6Qdj11TQ==,type:str] unencrypted_suffix: _unencrypted version: 3.13.2 diff --git a/modules/container-registry/token-signing.crt b/modules/container-registry/token-signing.crt deleted file mode 100644 index 85aaa4d2..00000000 --- a/modules/container-registry/token-signing.crt +++ /dev/null @@ -1,30 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFLTCCAxWgAwIBAgIUQdQPhfuPC0aPeFm2Q25sasQLaSIwDQYJKoZIhvcNAQEL -BQAwJjEkMCIGA1UEAwwbc2phbmdsYWItY29udGFpbmVyLXJlZ2lzdHJ5MB4XDTI2 -MDgwMjExMTUzN1oXDTM2MDczMDExMTUzN1owJjEkMCIGA1UEAwwbc2phbmdsYWIt -Y29udGFpbmVyLXJlZ2lzdHJ5MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKC -AgEA6TiHwDACaDj3xo5rVt8MmPI6X3KMJnEKv05t6DD16HKUCQ2TfD8XrmW5YQkG -KLyPjDkG3p9U1rgFanApi++462p0EUX0rwhw6oKXoLt6VhMdoZrKdrDvBpbMlIyH -GbI5PyrvKO0KsEEhD7cO1ybJPNRs9IkFmTMlp/3p0eUoxzkrIZFvq9aCszTnOGm4 -oZxrSDh5/okuu1u2XtyEVYL1KVC/8ovo8wEAbzUBRO5Rmof6jFqRApMKTSRPtSob -kDOTm8zkLNfd6OBIpOLxdcGmGJRZkPExTBf51XPGD+YRjHYI5GFkOfNjKNuY/Mkm -lVO2Wjc3vLg0AWqENQXpUPeJ97S9KDL5ULGAoKZ+Zv8u0NxwRWUClR+I8RZiGMNC -NiE9fwDHHfWHrJmSO6sWq3dYQUsiUv77iUNxCqyCLYCRJx5D8otWRcpC2W64PH1O -f/Lr7U97qxiyuDIj0QWu7LIWIrIsNFEeKfVQY8kI1NfYWp6UaLxEtCbWHJClv/oc -MmIW/p4invYR1C0wWc7Fg5lXfXr1ItbaveAZiQsHKIyc9Yz/cdUgWXWKvCutk4vd -RA09hYM/+0RmWvN0lJn5yTAfrN2Xw40GqiPhojwH0wGBzNWTUAzZD1QEvX/9Ufvi -bO0RDOz3AmnWtJeSsvljF5W4dnn9I2q2A6+FPRSZTB/hqnkCAwEAAaNTMFEwHQYD -VR0OBBYEFMe6D+la3ynreN4zD9MFAJS0B1ISMB8GA1UdIwQYMBaAFMe6D+la3ynr -eN4zD9MFAJS0B1ISMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIB -AAQZGjcCCB9Vjoq42f5vETnapOPl1KRXQhvgjdjbuu1H/7xRTGdDO0skiWdZrS9K -EZsJStRt5a1s9lqRJeGqTXr3yVh9rMFUUnF9n+70EsDnr1yeDZAcBK1WPZp3vGdD -dI+6BdGU6zu0oi2xoXdPtfxi91w4rK+2vRsgAKFVcNN1pCwid+chPZ//BTyF7MQ0 -9nq3iyTY/UzlHITQ+ewipou7E42DLF3DXYBNUX73SGAfkXcq1R+G7Nv19CxUj219 -t0NE5VVORdyfD2KSM3jYbH82x2lULJblNhKnBKU/F1oc/xY/emchqSP4nJvA+egO -/2sseFQ00LtZ2mv8PulUV7jdi496sDkeu/T38800QU0yinkuPPIxxaBZc2EkaIVf -aQhpRVUlI9RMkeOzEzw+wKc7WQZS7PT4U4vpcwG4ZOLrXiFtTTr/PVozUTsH682U -tLzbWkX0nzn/QzNuoDJeLh7Nzdl536nEksriVz79/kv658wviNC8Giki+ZhQRzWc -GR5A9FAOPfAuQkfncasoXtdtmwrvM51FwKY0lB+JvkxbgdFebX1G/UZgbMD0PCYp -DjSKR1q1w+jHsXUyWJREW551Faf1gqOinvuNCVS/VXW/JaIBZ+cqDqLv/FBNzqFP -y8Fs6DEN0tS7TgskRvkrtRW8kSDmrxa+NTZ0X1yrY3hm ------END CERTIFICATE----- From 148bec1c45202eb879c201892e593f5343f4724c Mon Sep 17 00:00:00 2001 From: mulatta <67085791+mulatta@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:21:23 +0900 Subject: [PATCH 5/7] docker-auth: remove zot now authenticates registry clients directly, leaving no users of the token server. Change-Id: I99b989f60b532de3e43ca6822f20e70456be6d57 --- packages/docker_auth/default.nix | 44 -------------------------------- packages/flake-module.nix | 1 - 2 files changed, 45 deletions(-) delete mode 100644 packages/docker_auth/default.nix diff --git a/packages/docker_auth/default.nix b/packages/docker_auth/default.nix deleted file mode 100644 index 2d57a07c..00000000 --- a/packages/docker_auth/default.nix +++ /dev/null @@ -1,44 +0,0 @@ -{ - lib, - buildGoModule, - fetchFromGitHub, -}: -buildGoModule (finalAttrs: { - pname = "docker_auth"; - version = "1.14.0"; - - src = fetchFromGitHub { - owner = "cesanta"; - repo = "docker_auth"; - tag = finalAttrs.version; - hash = "sha256-WeqgNxuLuckdcQj5AluyDRBuCuyoIaiiTM7AN+Dgr8s="; - }; - - modRoot = "auth_server"; - vendorHash = "sha256-dbSSmQ+Kl28NQ+4tw8wYy6oRcSQd88QLgaWe4wB3OMg="; - - ldflags = [ - "-X main.Version=${finalAttrs.version}" - "-X main.BuildID=nix" - ]; - - postInstall = '' - mv "$out/bin/auth_server" "$out/bin/docker_auth" - ''; - - doInstallCheck = true; - installCheckPhase = '' - runHook preInstallCheck - "$out/bin/docker_auth" -h >/dev/null - runHook postInstallCheck - ''; - - meta = { - description = "Authentication server for Docker Registry 2"; - homepage = "https://github.com/cesanta/docker_auth"; - changelog = "https://github.com/cesanta/docker_auth/releases/tag/${finalAttrs.version}"; - license = lib.licenses.asl20; - mainProgram = "docker_auth"; - platforms = lib.platforms.unix; - }; -}) diff --git a/packages/flake-module.nix b/packages/flake-module.nix index b16a3850..1ad35a8b 100644 --- a/packages/flake-module.nix +++ b/packages/flake-module.nix @@ -9,7 +9,6 @@ }: { packages = { - docker-auth = pkgs.callPackage ./docker_auth { }; infra-alert-bridge = pkgs.callPackage ./infra-alert-bridge { }; slack-cli = pkgs.callPackage ./slack-cli { buildGoModule = pkgs.buildGo127Module; }; slack-group-sync = pkgs.callPackage ./slack-group-sync { }; From 8d347b756c0cab84a09a9520d95aeeaf5f988043 Mon Sep 17 00:00:00 2001 From: mulatta <67085791+mulatta@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:17:13 +0900 Subject: [PATCH 6/7] r2: remove container-registry bucket zot now stores images on psi, so the bucket and its scoped token have no users. The bucket is already empty. Change-Id: Ifd31a82e85ae7f6d0febc62e7638066a1ab05750 --- docs/admin/terraform.md | 2 +- terraform/r2/README.md | 5 +---- terraform/r2/main.tf | 11 ----------- 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/docs/admin/terraform.md b/docs/admin/terraform.md index c21ed8bb..2efd2453 100644 --- a/docs/admin/terraform.md +++ b/docs/admin/terraform.md @@ -23,7 +23,7 @@ Nix가 provider 버전의 단일 기준입니다. 실행 cache는 unit별 `.terr | Unit | 책임 | |------|------| | `dns` | 공개 ingress가 필요한 Cloudflare DNS. 내부 서비스 이름은 Headscale split DNS | -| `r2` | 서비스 bucket·custom domain. niks3, OCI Registry, Stalwart blobs, eta Restic 등 | +| `r2` | 서비스 bucket·custom domain. niks3, Stalwart blobs, eta Restic 등 | | `authentik` | 사용자·그룹, proxy/OIDC provider, application, outpost attachment, policy·claim mapping | | `headscale` | Authentik과 같은 사용자 inventory에서 생성한 database ACL policy | | `healthchecksio` | dead-man checks와 기존 Slack integration 연결 | diff --git a/terraform/r2/README.md b/terraform/r2/README.md index d7c3923c..b3f3620e 100644 --- a/terraform/r2/README.md +++ b/terraform/r2/README.md @@ -3,14 +3,11 @@ This stack owns R2 buckets for object-heavy services: - `niks3`, plus its public read domain `cache.sjanglab.org` -- `container-registry`, used by the eta Docker/OCI Registry - Stalwart mail blob buckets - `eta-restic-backups`, reserved for eta PostgreSQL and Vaultwarden Restic repositories The niks3 bucket is protected from accidental Terraform destruction because -niks3 tracks object reachability separately in PostgreSQL. The container registry -bucket is protected for the same operational reason: registry manifests and R2 -objects must stay consistent. +niks3 tracks object reachability separately in PostgreSQL. Each service needs its own R2 S3 API token scoped to Object Read & Write on only its bucket. Cloudflare reveals that token's secret once, so bootstrap it outside diff --git a/terraform/r2/main.tf b/terraform/r2/main.tf index 9f721ace..e48f32c3 100644 --- a/terraform/r2/main.tf +++ b/terraform/r2/main.tf @@ -9,17 +9,6 @@ resource "cloudflare_r2_bucket" "niks3" { } } -resource "cloudflare_r2_bucket" "container_registry" { - account_id = data.sops_file.secrets.data["CLOUDFLARE_ACCOUNT_ID"] - name = "container-registry" - location = "apac" - storage_class = "Standard" - - lifecycle { - prevent_destroy = true - } -} - resource "cloudflare_r2_bucket" "stalwart_mail_blobs" { account_id = data.sops_file.secrets.data["CLOUDFLARE_ACCOUNT_ID"] name = "stalwart-mail-blobs" From 44887c4a0f6a981545de62cc6f953c2eda9d01b7 Mon Sep 17 00:00:00 2001 From: mulatta <67085791+mulatta@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:53:12 +0900 Subject: [PATCH 7/7] authentik: add registry OIDC application zot signs lab members in through authentik and maps the groups claim directly to repository permissions, so the provider sends plain group names. The application is limited to lab members because zot grants nothing to other authenticated users anyway. Change-Id: I7e2da7730f8c72d1b3fed3b669bd78307f67c378 --- terraform/authentik/access.tf | 2 ++ terraform/authentik/oidc-secrets.yaml | 5 +++-- terraform/authentik/oidc.tf | 27 +++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/terraform/authentik/access.tf b/terraform/authentik/access.tf index ab781687..2a2ac12d 100644 --- a/terraform/authentik/access.tf +++ b/terraform/authentik/access.tf @@ -16,6 +16,8 @@ locals { nextcloud = local.lab vaultwarden = local.lab quorum = local.lab + # zot maps the groups claim to repository permissions; staff get none. + registry = local.members } app_uuids = merge( diff --git a/terraform/authentik/oidc-secrets.yaml b/terraform/authentik/oidc-secrets.yaml index 9887ed64..4ad0f8e0 100644 --- a/terraform/authentik/oidc-secrets.yaml +++ b/terraform/authentik/oidc-secrets.yaml @@ -1,6 +1,7 @@ HEADSCALE_CLIENT_SECRET: ENC[AES256_GCM,data:nBjh6nhGKFFxMZeZnUi2vgpRhMlvL7+PKC9yYikucUIPgv/lheyw8/2DoC0P2xXQBCzyasPWPy+mlA4Kp+FqCly2CU2Rv6GDFqfgOVacWjzmUmBq7gVzPiShwT7VP3m8a583vjrYRu1NGnHFj6wRjN2Wqpg03cFpJ9vR4XA43HI=,iv:y9804e/yL1mOM//+Rk7sVT7ZnVCJiZGV/DWfvm6R7as=,tag:/Xsro33rPMEGPx9tuLBY5A==,type:str] VAULTWARDEN_CLIENT_SECRET: ENC[AES256_GCM,data:fXyrFHVEQXU/OC91eh8ppl4dtyA2XkzmPdpV6joGU0ogRpbOA7EJXQ1OiF+/MTmuCqpA/ld4MOBpDqmQVUiqgpqifvZB+hfztkP4NdUT2hMgci6MLOTgl2rqNl0orzT8ubHobZOeBEBw4d6e6Nsbj0luLwkOW0ASWMIgMtsvnfE=,iv:HzZzbNDBLsJOfiQDrFBeA3TcTDRLLImmvZJSm3dQG4E=,tag:+hgMYN0G1JCQDkvbUQ1oUA==,type:str] NEXTCLOUD_CLIENT_SECRET: ENC[AES256_GCM,data:A1Nu2k5VWwStV2MdosWZXg0EWxKtOxEbL+E1hOa9juBg9IiwPE7t20nEx/ED0uKKUq2CDTwlcU/n2/60KgZ6tw==,iv:MOmps8oQNQOq1JC0DNO3H/OByi69Kh62KS8dOTWgj+w=,tag:9bRuN8I+ABeM0wcmvVfzxw==,type:str] +REGISTRY_CLIENT_SECRET: ENC[AES256_GCM,data:1c3FoTqNOfFD4FsOJybGqSDBCvqITMn2rgIOyrNCklamsPW3iI5YiTKu5tlv90ew7JcpnjVLQvJNZySajxYI2Q==,iv:OlSkIvDxgt2oI63WP+wexib1b3rMYiFZQI5AQ5njW7k=,tag:z60xeXmUJwhBZ2pe5TqT6w==,type:str] sops: age: - enc: | @@ -39,7 +40,7 @@ sops: oUsuT8VZSdpn6QhHGTN8POk8p2M+HgS5qwvBVJMfrCmVmyveKYRIvw== -----END AGE ENCRYPTED FILE----- recipient: age15h90xndr3kn5d8et69y5hf54lclmvafhk5a0d3hgc0a9fvjexcaq0t9hvr - lastmodified: "2026-07-03T02:59:58Z" - mac: ENC[AES256_GCM,data:xCuMQqsJrHMHgFNoI5wcjGt01JDUtD3z8p9ofRafjjk6zN4eVd0t08Y+3YkuoZOjZajTX1SvzNrEHYANM+yC5jNgj7wIA7ugbNBmd+Wf05v9dSqLCZxcszVnoGcjywrZJzsuyIzb5km4MEEUJXtU+zTW/q7ObB4VF4YpHZnV0t8=,iv:AU9LyOfcf9K4lk6GOgCglV5Nk6Q8xQUNyUSBX2U+qCw=,tag:rfGb+LNBPxkmHHPQGlwr1A==,type:str] + lastmodified: "2026-11-13T04:38:56Z" + mac: ENC[AES256_GCM,data:SkVuVqmU98Xmp1+joArntIJ2Q1LOkqfH/w1JpI2snIm943J373cFFliqbI/lfV0/B9OEXBpl/14eVtKFtUpzgQ3s22gKsRWN9i27w7odtpq10NLQriMo7TLWWk1PQC8uuMZ/ZYi2ZICdTqJldpu8Ds3B7J8IGoJA3kadzkIZQ4g=,iv:DP4JrMG4/TOMqIPzzsyQtuAmBkMFISk7Y67z+2PvYdo=,tag:AWwaFql4tjUw9U5nBvQXLg==,type:str] unencrypted_suffix: _unencrypted version: 3.13.1 diff --git a/terraform/authentik/oidc.tf b/terraform/authentik/oidc.tf index b4df5169..d2ba7e7a 100644 --- a/terraform/authentik/oidc.tf +++ b/terraform/authentik/oidc.tf @@ -9,6 +9,15 @@ locals { } EOF } + registry = { + name = "registry-groups" + scope_name = "groups" + expression = <<-EOF + return { + "groups": [g.name for g in request.user.ak_groups.all()] + } + EOF + } nextcloud = { name = "nextcloud-groups" scope_name = "groups" @@ -135,6 +144,24 @@ locals { [authentik_property_mapping_provider_scope.quorum_slack.id], ) } + registry = { + name = "Container Registry" + slug = "registry" + client_id = "registry" + client_secret = data.sops_file.oidc_secrets.data["REGISTRY_CLIENT_SECRET"] + client_type = "confidential" + sub_mode = "hashed_user_id" + meta_launch_url = "https://registry.sjanglab.org" + allowed_redirect_uris = [ + { + matching_mode = "strict" + redirect_uri_type = "authorization" + url = "https://registry.sjanglab.org/zot/auth/callback/oidc" + }, + ] + group_property_mapping_keys = ["registry"] + property_mappings = local.oidc_default_property_mappings + } vaultwarden = { name = "Vaultwarden" slug = "vaultwarden"