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/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/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: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,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----- 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())))) 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 7895f230..1ad35a8b 100644 --- a/packages/flake-module.nix +++ b/packages/flake-module.nix @@ -4,15 +4,17 @@ { pkgs, lib, + self', ... }: { 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 { }; 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"; + }; +}) 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; + }; +} 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" 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"