Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
243 changes: 243 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
name: build

on:
schedule:
# Daily, so a Debian security update reaches the published image within 24h.
- cron: '0 4 * * *'
push:
branches: [master]
pull_request:
workflow_dispatch:

env:
IMAGE: ghcr.io/ls1admin/borgserver
BASE_IMAGE: debian:trixie-slim

permissions:
contents: read

concurrency:
group: build-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: shellcheck
run: shellcheck data/run.sh tests/lib.sh tests/unit_run_sh.sh tests/integration_test.sh

- name: Unit tests
run: bash tests/unit_run_sh.sh

build:
needs: lint
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write
id-token: write
strategy:
fail-fast: false
matrix:
include:
- series: '1.4'
package: borgbackup
borg_bin: /usr/bin/borg
primary_tag: latest
tags: latest,1.4,1,trixie
- series: '2.0'
package: borgbackup2
borg_bin: /usr/bin/borg2
primary_tag: '2.0'
tags: 2.0,2
steps:
- uses: actions/checkout@v4

- uses: imjasonh/setup-crane@v0.4

# Must stay unguarded (no `if:`) and run before the Trivy steps below:
# the Trivy containers bind-mount ${HOME}/.docker/config.json read-only.
# If login were skipped, that path would not exist yet and Docker would
# auto-create it as an empty *directory* on the first bind-mount, which
# breaks both Trivy scan steps for the rest of the job.
- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Resolves exactly what the image would contain, without building it:
# the base manifest digest, the version of every package apt would
# install or upgrade, and the tracked content of the build inputs.
- name: Compute fingerprint
id: fp
run: |
set -euo pipefail
base_digest="$(crane digest "${BASE_IMAGE}")"
echo "base_digest=${base_digest}" >> "$GITHUB_OUTPUT"
apt_plan="$(docker run --rm -e DEBIAN_FRONTEND=noninteractive "${BASE_IMAGE}" sh -c \
'apt-get update -qq >/dev/null 2>&1 && { apt-get -s dist-upgrade ; apt-get -s install --no-install-recommends openssh-server ${{ matrix.package }} ; } | grep "^Inst" | sort')"
src_hash="$(git ls-files -s Dockerfile data/run.sh data/sshd_config | sha256sum | cut -d' ' -f1)"
# apt_snapshot covers only the base image + resolved package set, so it
# keys the apt-get layer's cache: a source-only edit (run.sh,
# sshd_config) must not bust it. fingerprint additionally folds in
# src_hash and is what decides whether to skip a scheduled rebuild and
# what gets stamped into the image label -- a source edit must always
# change *that*.
apt_snapshot="$(printf '%s\n%s\n' "${base_digest}" "${apt_plan}" | sha256sum | cut -d' ' -f1)"
fingerprint="$(printf '%s\n%s\n' "${apt_snapshot}" "${src_hash}" | sha256sum | cut -d' ' -f1)"
echo "apt_snapshot=${apt_snapshot}" >> "$GITHUB_OUTPUT"
echo "fingerprint=${fingerprint}" >> "$GITHUB_OUTPUT"
echo "base: ${base_digest}"
echo "src: ${src_hash}"
echo "apt_snapshot: ${apt_snapshot}"
echo "fingerprint: ${fingerprint}"

# Only scheduled runs may skip. Pushes and manual runs always rebuild, so
# a tag or workflow change is always applied.
- name: Decide whether to build
id: check
run: |
set -euo pipefail
published="$(crane config "${IMAGE}:${{ matrix.primary_tag }}" 2>/dev/null \
| jq -r '.config.Labels["de.tum.cit.aet.borgserver.fingerprint"] // empty' || true)"
echo "published fingerprint: ${published:-<none>}"
if [ "${{ github.event_name }}" = "schedule" ] && [ -n "${published}" ] \
&& [ "${published}" = "${{ steps.fp.outputs.fingerprint }}" ] ; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Nothing changed, skipping the rebuild."
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi

- name: Expand tag list
id: tags
if: steps.check.outputs.skip == 'false'
run: |
set -euo pipefail
{
echo 'list<<EOF'
echo "${{ matrix.tags }}" | tr ',' '\n' | while read -r tag ; do
[ -n "${tag}" ] || continue
echo "${IMAGE}:${tag}"
done
echo 'EOF'
} >> "$GITHUB_OUTPUT"

- uses: docker/setup-buildx-action@v3
if: steps.check.outputs.skip == 'false'

- name: Build for testing
if: steps.check.outputs.skip == 'false'
uses: docker/build-push-action@v6
with:
context: .
load: true
push: false
provenance: false
tags: borgserver:test
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE }}@${{ steps.fp.outputs.base_digest }}
BORG_PACKAGE=${{ matrix.package }}
BORG_SERIES=${{ matrix.series }}
BORG_BIN=${{ matrix.borg_bin }}
APT_SNAPSHOT=${{ steps.fp.outputs.apt_snapshot }}
FINGERPRINT=${{ steps.fp.outputs.fingerprint }}
cache-from: type=gha,scope=${{ matrix.series }}
cache-to: type=gha,mode=max,scope=${{ matrix.series }}

- name: Smoke test
if: steps.check.outputs.skip == 'false'
env:
IMAGE: borgserver:test
BORG_PACKAGE: ${{ matrix.package }}
BORG_SERIES: ${{ matrix.series }}
BASE_IMAGE: ${{ env.BASE_IMAGE }}
run: bash tests/integration_test.sh

# Blocks the push, not just the publish: runs against the freshly built
# borgserver:test (already loaded into the local daemon by the build
# step above) so a CRITICAL fails the workflow before the image ever
# reaches ghcr.io, instead of after it is already public. Skipped along
# with the build, since borgserver:test does not exist on a skipped run.
- name: Fail on fixable CRITICAL vulnerabilities
if: steps.check.outputs.skip == 'false'
run: |
set -euo pipefail
mkdir -p "${HOME}/.cache/trivy"
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "${HOME}/.cache/trivy:/root/.cache/trivy" \
-v "${HOME}/.docker/config.json:/root/.docker/config.json:ro" \
aquasec/trivy:latest image \
--scanners vuln --ignore-unfixed --severity CRITICAL \
--exit-code 1 --format table \
borgserver:test

# Rebuilt from the cache populated above, so this only adds the push,
# the SBOM and the provenance attestation. The Dockerfile's borg-version
# assertion (see Dockerfile) still runs during this build, so a tag can
# never disagree with the borg version actually inside the image.
- name: Push
id: push
if: steps.check.outputs.skip == 'false' && github.event_name != 'pull_request'
uses: docker/build-push-action@v6
with:
context: .
push: true
provenance: mode=max
sbom: true
tags: ${{ steps.tags.outputs.list }}
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE }}@${{ steps.fp.outputs.base_digest }}
BORG_PACKAGE=${{ matrix.package }}
BORG_SERIES=${{ matrix.series }}
BORG_BIN=${{ matrix.borg_bin }}
APT_SNAPSHOT=${{ steps.fp.outputs.apt_snapshot }}
FINGERPRINT=${{ steps.fp.outputs.fingerprint }}
cache-from: type=gha,scope=${{ matrix.series }}

- uses: sigstore/cosign-installer@v3
if: steps.push.outputs.digest != ''

- name: Sign the pushed image
if: steps.push.outputs.digest != ''
run: cosign sign --yes "${IMAGE}@${{ steps.push.outputs.digest }}"

# Runs even when the build was skipped: a newly disclosed CVE in an
# already-published package does not change the fingerprint.
- name: Choose scan target
id: scan
run: |
set -euo pipefail
if [ "${{ steps.check.outputs.skip }}" = "true" ] ; then
echo "ref=${IMAGE}:${{ matrix.primary_tag }}" >> "$GITHUB_OUTPUT"
else
echo "ref=borgserver:test" >> "$GITHUB_OUTPUT"
fi

- name: Trivy scan (SARIF)
run: |
set -euo pipefail
mkdir -p "${HOME}/.cache/trivy"
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "${HOME}/.cache/trivy:/root/.cache/trivy" \
-v "${HOME}/.docker/config.json:/root/.docker/config.json:ro" \
-v "${PWD}:/out" \
aquasec/trivy:latest image \
--scanners vuln --ignore-unfixed --severity HIGH,CRITICAL \
--format sarif --output /out/trivy.sarif \
"${{ steps.scan.outputs.ref }}"

- name: Upload SARIF
if: github.event_name != 'pull_request'
continue-on-error: true
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy.sarif
category: trivy-${{ matrix.series }}
52 changes: 0 additions & 52 deletions .woodpecker.yml

This file was deleted.

58 changes: 49 additions & 9 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
############################################################
# Dockerfile to build borgbackup server images
# Based on Debian
# Based on Debian trixie
############################################################
ARG BASE_IMAGE=debian:bookworm-slim
FROM $BASE_IMAGE
ARG BASE_IMAGE=debian:trixie-slim
FROM ${BASE_IMAGE}

LABEL org.opencontainers.image.source="https://github.com/Nold360/borgserver"
# borgbackup (1.4.x) or borgbackup2 (2.0.x); BORG_BIN must match the package.
ARG BORG_PACKAGE=borgbackup
ARG BORG_SERIES=1.4
ARG BORG_BIN=/usr/bin/borg
# Fingerprint of the resolved apt package set. It only changes when a package
# in the closure changes, so it keys the cache of the layer below: unrelated
# edits reuse it, a security update busts it.
ARG APT_SNAPSHOT=unknown
ARG FINGERPRINT=unknown

LABEL org.opencontainers.image.source="https://github.com/ls1admin/borgserver" \
org.opencontainers.image.description="BorgBackup server over SSH, Debian trixie, borg ${BORG_SERIES}" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.version="${BORG_SERIES}" \
de.tum.cit.aet.borgserver.fingerprint="${FINGERPRINT}"

# Volume for SSH-Keys
VOLUME /sshkeys
Expand All @@ -14,21 +28,47 @@ VOLUME /sshkeys
VOLUME /backup

ENV DEBIAN_FRONTEND=noninteractive
ENV BORG_SERIES=${BORG_SERIES}
ENV BORG_BIN=${BORG_BIN}

RUN apt-get update && apt-get -y --no-install-recommends install \
borgbackup openssh-server && apt-get clean && \
useradd -s /bin/bash -m -U borg && \
RUN echo "apt snapshot: ${APT_SNAPSHOT}" && \
apt-get update && apt-get -y dist-upgrade && \
apt-get -y --no-install-recommends install ${BORG_PACKAGE} openssh-server && \
apt-get clean && \
useradd -s /bin/bash -m -U -p '*' borg && \
# -p '*' sets an unmatchable hash ("no password") without marking the
# account locked the way a bare useradd (shadow field '!') would; with
# UsePAM no in sshd_config, sshd itself enforces the locked-account
# check, so a locked account would be rejected before publickey auth.
mkdir /home/borg/.ssh && \
chmod 700 /home/borg/.ssh && \
chown borg:borg /home/borg/.ssh && \
mkdir -p /run/sshd && \
rm -f /etc/ssh/ssh_host*key* && \
rm -rf /var/lib/apt/lists/* /var/tmp/* /tmp/*

COPY ./data/run.sh /run.sh
COPY ./data/sshd_config /etc/ssh/sshd_config
# Fail the build rather than publish an image whose tag lies about its borg
# major version.
RUN set -eu ; \
if [ ! -x "${BORG_BIN}" ] ; then \
echo "ERROR: ${BORG_BIN} is missing or not executable" >&2 ; exit 1 ; \
fi ; \
installed="$(${BORG_BIN} -V | awk '{print $2}')" ; \
series="$(echo "${installed}" | cut -d. -f1,2)" ; \
if [ "${series}" != "${BORG_SERIES}" ] ; then \
echo "ERROR: expected borg ${BORG_SERIES}.x, image has ${installed}" >&2 ; exit 1 ; \
fi ; \
echo "borg version check ok: ${installed}"

COPY --chmod=0755 ./data/run.sh /run.sh
COPY --chmod=0644 ./data/sshd_config /etc/ssh/sshd_config

# Default SSH-Port for clients
EXPOSE 22

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD bash -c 'exec 3<>/dev/tcp/127.0.0.1/22' || exit 1

STOPSIGNAL SIGTERM

ENTRYPOINT ["/run.sh"]
Loading
Loading