Skip to content

Commit 992274a

Browse files
committed
feat(runtime): mirror hardened images through GHCR
1 parent b80e078 commit 992274a

15 files changed

Lines changed: 294 additions & 12 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
if [ "$#" -ne 5 ]; then
5+
echo "usage: $0 <name> <source-ref> <target-ref> <expected-index-digest> <required-arches>" >&2
6+
exit 2
7+
fi
8+
9+
name="$1"
10+
source_ref="$2"
11+
target_ref="$3"
12+
expected_digest="$4"
13+
required_arches_csv="$5"
14+
15+
tmpdir="$(mktemp -d)"
16+
trap 'rm -rf "$tmpdir"' EXIT
17+
18+
digest_file() {
19+
sha256sum "$1" | awk '{ print "sha256:" $1 }'
20+
}
21+
22+
source_raw="$tmpdir/source.json"
23+
target_raw="$tmpdir/target.json"
24+
25+
echo "::group::inspect source ${name}"
26+
skopeo inspect --raw "docker://${source_ref}" >"$source_raw"
27+
source_digest="$(digest_file "$source_raw")"
28+
if [ "$source_digest" != "$expected_digest" ]; then
29+
echo "source digest mismatch for ${name}: expected ${expected_digest}, got ${source_digest}" >&2
30+
exit 1
31+
fi
32+
33+
if ! jq -e '.manifests and (.manifests | length > 0)' "$source_raw" >/dev/null; then
34+
echo "source ${name} is not a multi-arch image index" >&2
35+
exit 1
36+
fi
37+
38+
IFS=',' read -r -a required_arches <<<"$required_arches_csv"
39+
for arch in "${required_arches[@]}"; do
40+
if ! jq -e --arg arch "$arch" '.manifests[] | select((.platform.os // "") == "linux" and (.platform.architecture // "") == $arch)' "$source_raw" >/dev/null; then
41+
echo "source ${name} is missing linux/${arch}" >&2
42+
exit 1
43+
fi
44+
done
45+
echo "source ${name} digest ${source_digest} contains ${required_arches_csv}"
46+
echo "::endgroup::"
47+
48+
echo "::group::copy ${name}"
49+
skopeo copy --retry-times 3 --all --preserve-digests "docker://${source_ref}" "docker://${target_ref}"
50+
echo "::endgroup::"
51+
52+
echo "::group::verify target ${name}"
53+
skopeo inspect --raw "docker://${target_ref}" >"$target_raw"
54+
target_digest="$(digest_file "$target_raw")"
55+
if [ "$target_digest" != "$expected_digest" ]; then
56+
echo "target digest mismatch for ${name}: expected ${expected_digest}, got ${target_digest}" >&2
57+
exit 1
58+
fi
59+
60+
for arch in "${required_arches[@]}"; do
61+
source_arch_digest="$(jq -r --arg arch "$arch" '.manifests[] | select((.platform.os // "") == "linux" and (.platform.architecture // "") == $arch) | .digest' "$source_raw" | head -n1)"
62+
target_arch_digest="$(jq -r --arg arch "$arch" '.manifests[] | select((.platform.os // "") == "linux" and (.platform.architecture // "") == $arch) | .digest' "$target_raw" | head -n1)"
63+
if [ -z "$target_arch_digest" ] || [ "$target_arch_digest" = "null" ]; then
64+
echo "target ${name} is missing linux/${arch}" >&2
65+
exit 1
66+
fi
67+
if [ "$target_arch_digest" != "$source_arch_digest" ]; then
68+
echo "target linux/${arch} digest mismatch for ${name}: expected ${source_arch_digest}, got ${target_arch_digest}" >&2
69+
exit 1
70+
fi
71+
done
72+
echo "target ${name} digest ${target_digest} matches source"
73+
echo "::endgroup::"
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
name: mirror-hardened-images
2+
3+
on:
4+
workflow_dispatch:
5+
schedule:
6+
- cron: "23 4 * * 1"
7+
push:
8+
branches:
9+
- main
10+
paths:
11+
- ".github/scripts/mirror-dhi-image.sh"
12+
- ".github/workflows/mirror-hardened-images.yml"
13+
- "ansible/site.yml"
14+
- "ansible/roles/idp_zitadel/defaults/main.yml"
15+
- "ansible/roles/oauth2_proxy/defaults/main.yml"
16+
17+
permissions:
18+
contents: read
19+
packages: write
20+
21+
jobs:
22+
mirror:
23+
runs-on: ubuntu-24.04
24+
strategy:
25+
fail-fast: false
26+
matrix:
27+
include:
28+
- name: oauth2-proxy
29+
source: dhi.io/oauth2-proxy:7.15.2-debian13@sha256:8f4e89762735e7ec7c3f1bbdd5da4dcd55358db8c3278bfbc2e46a7f86ab7d9e
30+
target: ghcr.io/terion-name/terrarium-dhi-oauth2-proxy:7.15.2-debian13
31+
package: terrarium-dhi-oauth2-proxy
32+
digest: sha256:8f4e89762735e7ec7c3f1bbdd5da4dcd55358db8c3278bfbc2e46a7f86ab7d9e
33+
arches: amd64,arm64
34+
- name: postgres
35+
source: dhi.io/postgres:17.9-alpine3.22-fips@sha256:ae0f0ac1f942ff7898bb217e599cc488b5c7a2611a0957daae44c00584a59714
36+
target: ghcr.io/terion-name/terrarium-dhi-postgres:17.9-alpine3.22-fips
37+
package: terrarium-dhi-postgres
38+
digest: sha256:ae0f0ac1f942ff7898bb217e599cc488b5c7a2611a0957daae44c00584a59714
39+
arches: amd64,arm64
40+
env:
41+
REGISTRY_AUTH_FILE: ${{ runner.temp }}/containers-auth.json
42+
HAS_DOCKERHUB_CREDS: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }}
43+
steps:
44+
- uses: actions/checkout@v4
45+
46+
- name: Install mirror tooling
47+
run: |
48+
sudo apt-get update
49+
sudo apt-get install -y jq skopeo
50+
51+
- name: Report missing Docker Hardened Images credentials
52+
if: env.HAS_DOCKERHUB_CREDS != 'true'
53+
run: |
54+
echo "::warning::DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets are required to mirror Docker Hardened Images."
55+
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
56+
exit 1
57+
fi
58+
59+
- name: Assert Docker Hardened Images credentials are configured
60+
if: env.HAS_DOCKERHUB_CREDS == 'true'
61+
env:
62+
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
63+
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
64+
run: |
65+
test -n "${DOCKERHUB_USERNAME}" || { echo "DOCKERHUB_USERNAME secret is required" >&2; exit 1; }
66+
test -n "${DOCKERHUB_TOKEN}" || { echo "DOCKERHUB_TOKEN secret is required" >&2; exit 1; }
67+
68+
- name: Login to Docker Hardened Images
69+
if: env.HAS_DOCKERHUB_CREDS == 'true'
70+
env:
71+
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
72+
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
73+
run: skopeo login dhi.io --username "${DOCKERHUB_USERNAME}" --password-stdin <<<"${DOCKERHUB_TOKEN}"
74+
75+
- name: Login to GHCR
76+
if: env.HAS_DOCKERHUB_CREDS == 'true'
77+
run: skopeo login ghcr.io --username "${GITHUB_ACTOR}" --password-stdin <<<"${{ secrets.GITHUB_TOKEN }}"
78+
79+
- name: Mirror pinned image index
80+
if: env.HAS_DOCKERHUB_CREDS == 'true'
81+
run: >-
82+
.github/scripts/mirror-dhi-image.sh
83+
"${{ matrix.name }}"
84+
"${{ matrix.source }}"
85+
"${{ matrix.target }}"
86+
"${{ matrix.digest }}"
87+
"${{ matrix.arches }}"
88+
89+
- name: Make GHCR package public when permitted
90+
if: env.HAS_DOCKERHUB_CREDS == 'true'
91+
continue-on-error: true
92+
env:
93+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
94+
PACKAGE_NAME: ${{ matrix.package }}
95+
run: |
96+
gh api --method PATCH "/user/packages/container/${PACKAGE_NAME}/visibility" -f visibility=public ||
97+
gh api --method PATCH "/orgs/${GITHUB_REPOSITORY_OWNER}/packages/container/${PACKAGE_NAME}/visibility" -f visibility=public

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Terrarium provisions the host with:
4444
- [OpenZFS](https://github.com/openzfs/zfs)
4545
- [sanoid and syncoid](https://github.com/jimsalterjrs/sanoid)
4646
- [Traefik](https://github.com/traefik/traefik) with the built-in dashboard for public management endpoints
47-
- [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy) for management OIDC gatekeeping, preferring Docker Hardened Images when registry credentials are present
47+
- [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy) for management OIDC gatekeeping, preferring Docker Hardened Images directly when registry credentials are present and Terrarium's GHCR mirror otherwise
4848
- Optional self-hosted [ZITADEL](https://github.com/zitadel/zitadel) at `auth.<domain>`, running as a Terrarium-managed LXD system instance
4949
- External OIDC issuer support when you do not want to self-host the IDP
5050
- [devsec.hardening](https://github.com/dev-sec/ansible-collection-hardening) OS and SSH hardening

ansible/roles/idp_zitadel/defaults/main.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ terrarium_zitadel_login_port: 8083
1212
terrarium_zitadel_version: v4.13.0
1313
terrarium_docker_registry_config_path: /root/.docker/config.json
1414
terrarium_docker_hardened_images: auto
15+
terrarium_docker_hardened_image_mirrors: true
1516
terrarium_zitadel_postgres_image: ""
1617
terrarium_zitadel_postgres_image_hardened: "dhi.io/postgres:17.9-alpine3.22-fips@sha256:ae0f0ac1f942ff7898bb217e599cc488b5c7a2611a0957daae44c00584a59714"
18+
terrarium_zitadel_postgres_image_mirror: "ghcr.io/terion-name/terrarium-dhi-postgres:17.9-alpine3.22-fips@sha256:ae0f0ac1f942ff7898bb217e599cc488b5c7a2611a0957daae44c00584a59714"
1719
terrarium_zitadel_postgres_image_fallback: "postgres:17.9-alpine3.22@sha256:034839bd88128360cda25496ebdb1471e24a4aa09b937160c73df2bb51126308"
1820
terrarium_zitadel_pat_expiration: "2099-01-01T00:00:00Z"
1921
terrarium_zitadel_project_name: Terrarium

ansible/roles/idp_zitadel/tasks/main.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,14 @@
185185
and (terrarium_zitadel_docker_registry_config.stat.exists | default(false))
186186
)
187187
)
188-
else terrarium_zitadel_postgres_image_fallback
188+
else (
189+
terrarium_zitadel_postgres_image_mirror
190+
if (
191+
(terrarium_docker_hardened_images | string | lower) not in ['false', 'no', '0']
192+
and (terrarium_docker_hardened_image_mirrors | string | lower) in ['true', 'yes', '1', 'auto']
193+
)
194+
else terrarium_zitadel_postgres_image_fallback
195+
)
189196
)
190197
}}
191198
when: terrarium_zitadel_postgres_image_effective is not defined

ansible/roles/oauth2_proxy/defaults/main.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ terrarium_oauth2_proxy_enabled: "{{ terrarium_idp_mode in ['local', 'oidc'] }}"
22
terrarium_oauth2_proxy_dir: "{{ terrarium_state_dir }}/oauth2-proxy"
33
terrarium_docker_registry_config_path: /root/.docker/config.json
44
terrarium_docker_hardened_images: auto
5+
terrarium_docker_hardened_image_mirrors: true
56
terrarium_oauth2_proxy_image: ""
67
terrarium_oauth2_proxy_image_hardened: "dhi.io/oauth2-proxy:7.15.2-debian13@sha256:8f4e89762735e7ec7c3f1bbdd5da4dcd55358db8c3278bfbc2e46a7f86ab7d9e"
8+
terrarium_oauth2_proxy_image_mirror: "ghcr.io/terion-name/terrarium-dhi-oauth2-proxy:7.15.2-debian13@sha256:8f4e89762735e7ec7c3f1bbdd5da4dcd55358db8c3278bfbc2e46a7f86ab7d9e"
79
terrarium_oauth2_proxy_image_fallback: "quay.io/oauth2-proxy/oauth2-proxy:v7.15.2@sha256:aa0bd8dd5ab0c78e4c91c92755ad573a5f92241f88138b4141b8ec803463b4fd"
810
terrarium_oauth2_proxy_uid: "65532"
911
terrarium_oauth2_proxy_gid: "65532"

ansible/roles/oauth2_proxy/tasks/main.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,14 @@
4848
and (terrarium_oauth2_proxy_docker_registry_config.stat.exists | default(false))
4949
)
5050
)
51-
else terrarium_oauth2_proxy_image_fallback
51+
else (
52+
terrarium_oauth2_proxy_image_mirror
53+
if (
54+
(terrarium_docker_hardened_images | string | lower) not in ['false', 'no', '0']
55+
and (terrarium_docker_hardened_image_mirrors | string | lower) in ['true', 'yes', '1', 'auto']
56+
)
57+
else terrarium_oauth2_proxy_image_fallback
58+
)
5259
)
5360
}}
5461
when: terrarium_oauth2_proxy_image_effective is not defined

ansible/site.yml

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,14 @@
5151
terrarium_zitadel_outputs_path: /etc/terrarium/zitadel-apps.json
5252
terrarium_docker_registry_config_path: /root/.docker/config.json
5353
terrarium_docker_hardened_images: auto
54+
terrarium_docker_hardened_image_mirrors: true
5455
terrarium_oauth2_proxy_image: ""
5556
terrarium_oauth2_proxy_image_hardened: "dhi.io/oauth2-proxy:7.15.2-debian13@sha256:8f4e89762735e7ec7c3f1bbdd5da4dcd55358db8c3278bfbc2e46a7f86ab7d9e"
57+
terrarium_oauth2_proxy_image_mirror: "ghcr.io/terion-name/terrarium-dhi-oauth2-proxy:7.15.2-debian13@sha256:8f4e89762735e7ec7c3f1bbdd5da4dcd55358db8c3278bfbc2e46a7f86ab7d9e"
5658
terrarium_oauth2_proxy_image_fallback: "quay.io/oauth2-proxy/oauth2-proxy:v7.15.2@sha256:aa0bd8dd5ab0c78e4c91c92755ad573a5f92241f88138b4141b8ec803463b4fd"
5759
terrarium_zitadel_postgres_image: ""
5860
terrarium_zitadel_postgres_image_hardened: "dhi.io/postgres:17.9-alpine3.22-fips@sha256:ae0f0ac1f942ff7898bb217e599cc488b5c7a2611a0957daae44c00584a59714"
61+
terrarium_zitadel_postgres_image_mirror: "ghcr.io/terion-name/terrarium-dhi-postgres:17.9-alpine3.22-fips@sha256:ae0f0ac1f942ff7898bb217e599cc488b5c7a2611a0957daae44c00584a59714"
5962
terrarium_zitadel_postgres_image_fallback: "postgres:17.9-alpine3.22@sha256:034839bd88128360cda25496ebdb1471e24a4aa09b937160c73df2bb51126308"
6063
terrarium_email: ""
6164
terrarium_acme_email: "{{ terrarium_email }}"
@@ -155,6 +158,14 @@
155158
)
156159
}}
157160
no_log: true
161+
- name: Resolve hardened image mirror preference
162+
ansible.builtin.set_fact:
163+
terrarium_docker_hardened_image_mirrors_effective: >-
164+
{{
165+
(terrarium_docker_hardened_images | string | lower) not in ['false', 'no', '0']
166+
and (terrarium_docker_hardened_image_mirrors | string | lower) in ['true', 'yes', '1', 'auto']
167+
}}
168+
no_log: true
158169
- name: Resolve container image defaults
159170
ansible.builtin.set_fact:
160171
terrarium_oidc_issuer_effective: >-
@@ -170,7 +181,11 @@
170181
else (
171182
terrarium_oauth2_proxy_image_hardened
172183
if terrarium_docker_hardened_images_effective
173-
else terrarium_oauth2_proxy_image_fallback
184+
else (
185+
terrarium_oauth2_proxy_image_mirror
186+
if terrarium_docker_hardened_image_mirrors_effective
187+
else terrarium_oauth2_proxy_image_fallback
188+
)
174189
)
175190
}}
176191
terrarium_zitadel_postgres_image_effective: >-
@@ -180,7 +195,11 @@
180195
else (
181196
terrarium_zitadel_postgres_image_hardened
182197
if terrarium_docker_hardened_images_effective
183-
else terrarium_zitadel_postgres_image_fallback
198+
else (
199+
terrarium_zitadel_postgres_image_mirror
200+
if terrarium_docker_hardened_image_mirrors_effective
201+
else terrarium_zitadel_postgres_image_fallback
202+
)
184203
)
185204
}}
186205
no_log: true

docs/getting-started/installation.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Terrarium installs onto a single Ubuntu 24.04 VPS and turns it into a hardened h
77
- Ubuntu Server 24.04 LTS
88
- root access on the host
99
- SSH key-based access
10-
- Optional Docker Hardened Images registry access. When `/root/.docker/config.json` is present, Terrarium prefers its hardened oauth2-proxy and local ZITADEL Postgres images; otherwise it uses pinned public fallbacks. Set `terrarium_docker_hardened_images: true` or explicit image variables when you want installation to fail closed unless hardened images are available.
10+
- Optional Docker Hardened Images registry access. Terrarium first uses the upstream DHI registry when `/root/.docker/config.json` is present, then uses Terrarium's public GHCR mirror of the same pinned DHI image indexes, and only uses pinned upstream public fallbacks when hardened image use or mirrors are disabled. Set `terrarium_docker_hardened_images: true` or explicit image variables when you want installation to fail closed unless the upstream DHI images are available.
1111
- either:
1212
- a dedicated extra disk for the LXD ZFS pool, which is the recommended setup
1313
- or enough root-disk space to use `--storage-mode file`
@@ -94,6 +94,16 @@ Terrarium also verifies the most failure-prone integrations while you configure
9494
- external OIDC settings are probed against the issuer, callback flow, and client credentials before install continues
9595
- S3 settings are tested with a real write/delete probe against the configured bucket
9696

97+
## Container Image Sources
98+
99+
Terrarium pins the oauth2-proxy and local ZITADEL Postgres images by digest. The default source order is:
100+
101+
- upstream Docker Hardened Images from `dhi.io` when Docker registry credentials exist on the host
102+
- Terrarium's GHCR mirror of those same DHI multi-arch indexes when upstream DHI credentials are not present
103+
- the pinned public upstream images when `terrarium_docker_hardened_images` or `terrarium_docker_hardened_image_mirrors` is disabled
104+
105+
The GHCR mirror is refreshed by CI with Docker Hub credentials, copies every platform in the pinned index, and verifies the copied index and required `linux/amd64` and `linux/arm64` manifests before publishing.
106+
97107
In interactive mode, failed verification sends you back to the relevant prompts. In non-interactive mode, install exits with an error instead of persisting broken settings.
98108

99109
For non-interactive automation, use generated or file-based secret inputs so

docs/reference/services-and-endpoints.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ Terrarium provisions the host with:
1111
- [OpenZFS](https://github.com/openzfs/zfs)
1212
- [sanoid and syncoid](https://github.com/jimsalterjrs/sanoid)
1313
- [Traefik](https://github.com/traefik/traefik) with the built-in dashboard
14-
- [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy), preferring Docker Hardened Images when registry credentials are present
15-
- Optional self-hosted [ZITADEL](https://github.com/zitadel/zitadel), running as the `terrarium-idp` LXD system instance with a Postgres sidecar that also prefers Docker Hardened Images when registry credentials are present
14+
- [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy), preferring Docker Hardened Images directly when registry credentials are present and Terrarium's GHCR mirror otherwise
15+
- Optional self-hosted [ZITADEL](https://github.com/zitadel/zitadel), running as the `terrarium-idp` LXD system instance with a Postgres sidecar that uses the same upstream-DHI, GHCR-mirror, public-fallback image order
1616
- Open vSwitch/OVN for the Terrarium LXD workload network
1717
- [devsec.hardening](https://github.com/dev-sec/ansible-collection-hardening)
1818

0 commit comments

Comments
 (0)