diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..5804532 --- /dev/null +++ b/.github/workflows/build.yml @@ -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:-}" + 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<> "$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 }} diff --git a/.woodpecker.yml b/.woodpecker.yml deleted file mode 100644 index 8fcf82d..0000000 --- a/.woodpecker.yml +++ /dev/null @@ -1,52 +0,0 @@ ---- -matrix: - include: - - BASE: unstable-slim - TAGS: '[ "unstable" ]' - PLATFORMS: linux/386,linux/amd64 - - BASE: trixie-slim - TAGS: '[ "trixie", "1.4", "latest" ]' - PLATFORMS: linux/386,linux/amd64,linux/arm/v7,linux/arm64/v8 - - BASE: bookworm-slim - TAGS: '[ "bookworm", "1.2", "oldstable" ]' - PLATFORMS: linux/386,linux/amd64,linux/arm/v7,linux/arm64/v8 - -variables: - - &build-settings - repo: nold360/borgserver,ghcr.io/nold360/borgserver - tags: ${TAGS} - platforms: ${PLATFORMS} - build_args: - BASE_IMAGE: debian:${BASE} - -steps: - - name: test-build - image: woodpeckerci/plugin-docker-buildx - settings: - dry-run: true - <<: *build-settings - when: - - event: [manual, push] - branch: - exclude: [ master ] - - - name: build-and-release - image: woodpeckerci/plugin-docker-buildx - settings: - <<: *build-settings - logins: - - registry: https://index.docker.io/v1/ - username: - from_secret: docker_username - password: - from_secret: docker_password - - - registry: https://ghcr.io/v1/ - repo: ghcr.io/nold360/borgserver - username: - from_secret: docker_username - password: - from_secret: gh_push_token - when: - - branch: master - event: [cron, manual, push] diff --git a/Dockerfile b/Dockerfile index 71d187a..37a799d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 @@ -14,10 +28,18 @@ 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 && \ @@ -25,10 +47,28 @@ RUN apt-get update && apt-get -y --no-install-recommends install \ 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"] diff --git a/README.md b/README.md index b7ef7a2..6cf826d 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ docker run -td \ -p 2222:22 \ --volume ./borg/sshkeys:/sshkeys \ --volume ./borg/backup:/backup \ - nold360/borgserver:latest + ghcr.io/ls1admin/borgserver:latest ``` @@ -45,7 +45,7 @@ See the the documentation for all available arguments: [borgbackup.readthedocs.i ##### Example ``` -docker run --rm -e BORG_SERVE_ARGS="--progress --debug" (...) nold360/borgserver +docker run --rm -e BORG_SERVE_ARGS="--progress --debug" (...) ghcr.io/ls1admin/borgserver ``` #### BORG_APPEND_ONLY @@ -62,7 +62,7 @@ To declare a client as admin, set this variable to the name of the client/sshkey ##### Example ``` -docker run --rm -e BORG_APPEND_ONLY="yes" -e BORG_ADMIN="nolds_notebook" (...) nold360/borgserver +docker run --rm -e BORG_APPEND_ONLY="yes" -e BORG_ADMIN="nolds_notebook" (...) ghcr.io/ls1admin/borgserver ``` To prune repos from another client, you have to add the path to the repository in the clients directory: @@ -79,6 +79,16 @@ Used to set the user id of the `borg` user inside the container. This can be use Used to set the group id of the `borg` group inside the container. This can be useful when the container has to access resources on the host with a specific group id. +#### BORG_CHOWN +Controls how ownership of `/backup` is fixed at startup. + + - `auto` (default) — recursively chown `/backup` only when its top-level ownership does not match + the `borg` user, e.g. on first start or after changing PUID/PGID. Newly created client + directories are always chowned. This avoids walking a multi-terabyte repository on every start. + - `always` — recursively chown `/backup` on every start (the old behaviour). + - `never` — never chown anything; you manage ownership yourself. + + ### Persistent Storages & Client Configuration We will need two persistent storage directories for our borgserver to be usefull. @@ -86,10 +96,24 @@ We will need two persistent storage directories for our borgserver to be usefull This directory has two subdirectories: ##### /sshkeys/clients/ -Here we will put all SSH public keys from our borg clients, we want to backup. Every key must be it's own file, containing only one line, with the key. The name of the file will become the name of the borg repository, we need for our client to connect. +Here we will put all SSH public keys from our borg clients, we want to backup. Every key must be it's own file. The name of the file will become the name of the borg repository, we need for our client to connect. That means every client get's it's own repository. So you might want to use the hostname of the client as the name of the sshkey file. +Filenames must consist only of letters, digits, dots, underscores and dashes (`[A-Za-z0-9._-]`). +The filename becomes a directory name and part of the forced SSH command, so anything else is +rejected and the key is skipped with a warning rather than imported. + +**Migrating an existing `/sshkeys/clients` directory:** if any filename contains a character outside +that set — e.g. `root@web01` or a `+` — rename it first. Such a file is now skipped with a warning +instead of being imported, and if every file in the directory is rejected the container exits with +an error instead of starting. + +Each file contributes exactly one key: the first non-comment line, which must be a bare public key. +A line carrying its own `authorized_keys` options is rejected, and any additional keys in the file +are ignored — otherwise a second line could grant access without the forced command that confines a +client to its own repository. + Hidden files & files inside of hidden directories will be ignored! ``` @@ -112,7 +136,7 @@ In this directory will borg write all the client data to. It's best to start wit ## Example Setup ### docker-compose.yml -Here is a quick example, how to run borgserver using docker-compose: [docker-compose.yml](https://github.com/Nold360/docker-borgserver/blob/master/docker-compose.yml) +Here is a quick example, how to run borgserver using docker-compose: [docker-compose.yml](https://github.com/ls1admin/borgserver/blob/master/docker-compose.yml) ### ~/.ssh/config for clients With this configuration (on your borg client) you can easily connect to your borgserver. @@ -133,17 +157,108 @@ And create your first backup! $ borg create backup:my_first_borg_repo::documents-2017-11-01 /home/user/MyImportentDocs ``` +### Automatic nightly updates + +Because the image is rebuilt daily and only republished when something actually changed (see +[Rebuild policy](#rebuild-policy)), a nightly `pull` is cheap: most nights the digest is identical, +nothing is downloaded and the container keeps running. On the nights a security fix does land, you +get it without touching anything. + +Two ways to set that up: + +**a) watchtower, as part of the compose stack.** The +[docker-compose.yml](https://github.com/ls1admin/borgserver/blob/master/docker-compose.yml) ships a +commented-out `watchtower` service — uncomment it to enable. It checks at 04:00 nightly, and pulls +and restarts *only* if the digest changed. `--label-enable` scopes it to containers carrying +`com.centurylinklabs.watchtower.enable=true`, which is already set on the `borgserver` service, so +it will not touch anything else on the host. + +Note that this mounts `/var/run/docker.sock`, which gives the watchtower container root-equivalent +control of the host. That is a real trade-off on a machine whose job is holding your backups. + +**b) A host cron job or systemd timer.** No extra container and no exposed docker socket: + +``` +0 4 * * * cd /srv/borgserver && docker compose pull -q && docker compose up -d +``` + +`docker compose up -d` is a no-op when the image digest has not changed, so this only restarts the +container on a real update. + +**Pick the window carefully.** Either approach restarts the container, which drops any backup that +is in flight — the client's SSH connection dies mid-transfer. Borg is designed to survive this (the +next run resumes; no archive is left half-committed), but the repository can be left holding a stale +lock that the client clears with `borg break-lock`. Schedule the update so it cannot overlap your +clients' backup runs. + +This only works while you track a moving tag such as `latest`, `1.4` or `1`. If you pin to a digest, +updates are deliberately your call and neither approach will do anything. + ## Docker Releases -All images are freshly built and published to both Docker Hub and GitHub Container Registry with the following tags: - - Stable - [borg version](https://packages.debian.org/trixie/borgbackup): `trixie`, `latest` - - Old Stable - [borg version](https://packages.debian.org/bookworm/borgbackup): `bookworm` - - Unstable - [borg version](https://packages.debian.org/sid/borgbackup): `unstable` +Images are published to the GitHub Container Registry only: + +| Tag | Contents | +| --- | --- | +| `latest`, `1.4`, `1`, `trixie` | Debian trixie + borgbackup 1.4.x — **use this one** | +| `2.0`, `2` | Debian trixie + borgbackup2 2.0.0bNN — upstream **beta**, see the warning below | + +``` +ghcr.io/ls1admin/borgserver:latest +``` + +The borg major version is pinned by the Debian suite: trixie carries the 1.4 line for the life of +the release, so `:1.4` cannot silently become a different major version. The build fails rather than +publishing an image whose tag disagrees with the borg version inside it. + +**Platform:** images are built for `linux/amd64` only. The previous Woodpecker CI also published +`linux/386`, `linux/arm/v7` and `linux/arm64/v8`; this workflow does not, so pulling on an arm64 host +(e.g. a Raspberry Pi) will fail with a "no matching manifest" error rather than silently pulling amd64. + +### Rebuild policy + +The image is rebuilt daily and republished only when something actually changed — a new base image, +a new version of any package in the dependency closure, or a change to this repository. Debian +backports security fixes into trixie's packages, so a daily rebuild puts an openssh or borg CVE fix +in the published image within 24 hours. If nothing changed, no new image is pushed and the digest +stays stable, so `docker pull` is a no-op. + +Every published image is scanned with Trivy, ships an SBOM and provenance attestation, and is signed +with cosign. To verify: + +``` +cosign verify ghcr.io/ls1admin/borgserver:latest \ + --certificate-identity-regexp '^https://github\.com/ls1admin/borgserver/' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +``` + +### About the 2.x images + +Borg 2.x is **upstream beta software** and its repository format is incompatible with 1.x. A 2.x +server only serves 2.x clients, and 1.x repositories cannot be used with it without an explicit +`borg transfer`. It is published for testing; `latest` will never point at it while upstream calls +it beta. + +#### SSH URL path semantics in borg 2.x + +Borg 2.x interprets SSH URLs differently from borg 1.x when using relative vs. absolute paths: + +- **borg 2.x:** A URL with a double slash (`ssh://user@host//abs/path`) is absolute; a single slash + (`ssh://user@host/rel/path`) is relative to the server's working directory. +- **borg 1.x:** A single slash always means absolute. + +Because this server's forced command changes the working directory (`cd /backup/`), a 2.x +client must use the **double-slash form** for absolute paths. For example, to create a repository +at `/backup/myclient/myrepo`, use: + +``` +borg2 repo-create --repo ssh://borg@backupserver//backup/myclient/myrepo +``` -### Docker Hub -- `nold360/borgserver` +Borg 1.x clients continue to use the single-slash form and are unaffected by this difference. -### GitHub Container Registry -- `ghcr.io/nold360/borgserver` +### Client version compatibility -All images are built every week and include the latest security updates and bug fixes. The same tags are pushed to both Docker Hub and GitHub Container Registry. +A 1.4 server serves older borg 1.x clients — including the 1.2.8 shipped by Ubuntu 24.04 — because +borg does nearly all work client-side and keeps `borg serve` compatible across the 1.x series. Use +`:latest` regardless of your clients' 1.x version. diff --git a/data/run.sh b/data/run.sh index 215cbe6..c524b5c 100755 --- a/data/run.sh +++ b/data/run.sh @@ -1,99 +1,259 @@ #!/bin/bash -# Start Script for docker-borgserver +# Start script for docker-borgserver. +# +# The file is safe to source: it only defines functions and defaults at file +# scope, and main() runs only when the script is executed directly. tests/ +# depends on that. -PUID=${PUID:-1000} -PGID=${PGID:-1000} +BORG_DATA_DIR="${BORG_DATA_DIR:-/backup}" +SSH_KEY_DIR="${SSH_KEY_DIR:-/sshkeys}" +AUTHORIZED_KEYS_PATH="${AUTHORIZED_KEYS_PATH:-/home/borg/.ssh/authorized_keys}" +BORG_BIN="${BORG_BIN:-/usr/bin/borg}" +BORG_SERIES="${BORG_SERIES:-1.4}" +BORG_SERVE_ARGS="${BORG_SERVE_ARGS:-}" +BORG_APPEND_ONLY="${BORG_APPEND_ONLY:-no}" +BORG_ADMIN="${BORG_ADMIN:-}" +BORG_CHOWN="${BORG_CHOWN:-auto}" +PUID="${PUID:-1000}" +PGID="${PGID:-1000}" -usermod -o -u "$PUID" borg &>/dev/null -groupmod -o -g "$PGID" borg &>/dev/null +# A client key filename becomes a directory name and part of a forced command, +# so it is restricted to a charset with no shell or path meaning. +VALID_CLIENT_NAME_RE='^[A-Za-z0-9._-]+$' -BORG_DATA_DIR=/backup -SSH_KEY_DIR=/sshkeys -BORG_CMD='cd ${BORG_DATA_DIR}/${client_name}; borg serve --restrict-to-path ${BORG_DATA_DIR}/${client_name} ${BORG_SERVE_ARGS}' -AUTHORIZED_KEYS_PATH=/home/borg/.ssh/authorized_keys +KNOWN_KEY_TYPES='ssh-ed25519 ssh-rsa ecdsa-sha2-nistp256 ecdsa-sha2-nistp384 ecdsa-sha2-nistp521 sk-ssh-ed25519@openssh.com sk-ecdsa-sha2-nistp256@openssh.com' -# Append only mode? -BORG_APPEND_ONLY=${BORG_APPEND_ONLY:=no} +is_valid_client_name() { + local name="${1-}" + [[ "$name" == "." || "$name" == ".." ]] && return 1 + [[ "$name" =~ $VALID_CLIENT_NAME_RE ]] +} -source /etc/os-release -echo "########################################################" -echo -n " * Docker BorgServer powered by " -borg -V -echo " * Based on ${PRETTY_NAME}" -echo "########################################################" -echo " * User id: $(id -u borg)" -echo " * Group id: $(id -g borg)" -echo "########################################################" +is_known_key_type() { + local type="${1-}" known + for known in $KNOWN_KEY_TYPES ; do + [[ "$type" == "$known" ]] && return 0 + done + return 1 +} +# Print the single public key a client key file is allowed to contribute. +# The first meaningful line must be a bare public key: a line carrying its own +# authorized_keys options would override the forced command we prepend, and any +# further line would become a second, unrestricted entry. +extract_first_key() { + local keyfile="${1-}" line type + while IFS= read -r line || [[ -n "$line" ]] ; do + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -z "$line" || "$line" == '#'* ]] && continue + type="${line%%[[:space:]]*}" + is_known_key_type "$type" || return 1 + printf '%s\n' "$line" + return 0 + done < "$keyfile" + return 1 +} -# Precheck if BORG_ADMIN is set -if [ "${BORG_APPEND_ONLY}" == "yes" ] && [ -z "${BORG_ADMIN}" ] ; then - echo "WARNING: BORG_APPEND_ONLY is active, but no BORG_ADMIN was specified!" -fi +is_valid_public_key() { + local line="${1-}" tmp rc + tmp="$(mktemp)" || return 1 + printf '%s\n' "$line" > "$tmp" + ssh-keygen -lf "$tmp" >/dev/null 2>&1 + rc=$? + rm -f "$tmp" + return $rc +} -# Precheck directories & client ssh-keys -for dir in BORG_DATA_DIR SSH_KEY_DIR ; do - dirpath=$(eval echo '$'${dir}) - echo " * Testing Volume ${dir}: ${dirpath}" - if [ ! -d "${dirpath}" ] ; then - echo "ERROR: ${dirpath} is no directory!" - exit 1 - fi +# The 2.x series dropped --append-only in favour of the permissions system. +append_only_flag() { + case "${1-}" in + 2.*) printf -- '--permissions=no-delete' ;; + *) printf -- '--append-only' ;; + esac +} - if [ "$(find ${SSH_KEY_DIR}/clients ! -regex '.*/\..*' -a -type f | wc -l)" == "0" ] ; then - echo "ERROR: No SSH-Pubkey file found in ${SSH_KEY_DIR}" - exit 1 +escape_authorized_keys_value() { + local value="${1-}" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + printf '%s' "$value" +} + +# build_forced_command +build_forced_command() { + local repo_path="${1-}" append_only="${2-}" series="${3-}" serve_args="${4-}" borg_bin="${5-}" + local cmd + printf -v cmd 'cd %s; %s serve --restrict-to-path %s' "$repo_path" "$borg_bin" "$repo_path" + if [[ -n "$serve_args" ]] ; then + cmd+=" ${serve_args}" + fi + if [[ "$append_only" == "yes" ]] ; then + cmd+=" $(append_only_flag "$series")" fi -done - -# Create SSH-Host-Keys on persistent storage, if not exist -mkdir -p ${SSH_KEY_DIR}/host 2>/dev/null -echo " * Checking / Preparing SSH Host-Keys..." -for keytype in ed25519 rsa ; do - if [ ! -f "${SSH_KEY_DIR}/host/ssh_host_${keytype}_key" ] ; then - echo " ** Creating SSH Hostkey [${keytype}]..." - ssh-keygen -q -f "${SSH_KEY_DIR}/host/ssh_host_${keytype}_key" -N '' -t ${keytype} + printf '%s' "$cmd" +} + +authorized_keys_line() { + printf 'restrict,command="%s" %s\n' "$(escape_authorized_keys_value "${1-}")" "${2-}" +} + +chown_borg() { + [[ $EUID -eq 0 ]] || return 0 + chown "$(id -u borg):$(id -g borg)" "$@" +} + +# import_client_keys +# Returns 1 if no usable client key was found. +import_client_keys() { + local key_dir="$1" data_dir="$2" akp="$3" + local keyfile client_name repo_path key_line forced_cmd append_only imported=0 + + : > "$akp" + chmod 0600 "$akp" + + while IFS= read -r -d '' keyfile ; do + client_name="$(basename -- "$keyfile")" + + if ! is_valid_client_name "$client_name" ; then + echo " !! Skipping '${client_name}': name must match ${VALID_CLIENT_NAME_RE}" + continue + fi + + if ! key_line="$(extract_first_key "$keyfile")" ; then + echo " !! Skipping '${client_name}': no bare public key on the first non-comment line" + continue + fi + + if ! is_valid_public_key "$key_line" ; then + echo " !! Skipping '${client_name}': ssh-keygen does not accept its public key" + continue + fi + + if [[ -n "$BORG_ADMIN" && "$client_name" == "$BORG_ADMIN" ]] ; then + repo_path="$data_dir" + append_only="no" + echo " ** Adding client ${client_name} as BORG_ADMIN with full access to ${repo_path}" + else + repo_path="${data_dir}/${client_name}" + append_only="$BORG_APPEND_ONLY" + echo " ** Adding client ${client_name} with repo path ${repo_path}" + fi + + if [[ ! -d "$repo_path" ]] ; then + mkdir -p "$repo_path" + chown_borg "$repo_path" + fi + + forced_cmd="$(build_forced_command "$repo_path" "$append_only" "$BORG_SERIES" "$BORG_SERVE_ARGS" "$BORG_BIN")" + authorized_keys_line "$forced_cmd" "$key_line" >> "$akp" + imported=$((imported + 1)) + done < <(find "$key_dir" ! -regex '.*/\..*' -a -type f -print0 | sort -z) + + [[ $imported -gt 0 ]] +} + +generate_host_keys() { + local key_dir="$1" keytype + mkdir -p "${key_dir}/host" + for keytype in ed25519 rsa ; do + if [[ ! -f "${key_dir}/host/ssh_host_${keytype}_key" ]] ; then + echo " ** Creating SSH Hostkey [${keytype}]..." + ssh-keygen -q -f "${key_dir}/host/ssh_host_${keytype}_key" -N '' -t "$keytype" + fi + done +} + +# Recursively chowning the data dir on every start is O(repository size); with +# the default policy it only happens when the top-level ownership is actually +# wrong, e.g. on first start or after a PUID/PGID change. New client +# directories are chowned as they are created, in import_client_keys. +sync_ownership() { + local data_dir="$1" uid gid + case "$BORG_CHOWN" in + never|always|auto) ;; + *) + echo "ERROR: BORG_CHOWN must be one of auto|always|never (got '${BORG_CHOWN}')" >&2 + return 1 + ;; + esac + [[ $EUID -eq 0 ]] || return 0 + uid="$(id -u borg)" + gid="$(id -g borg)" + case "$BORG_CHOWN" in + never) + return 0 + ;; + always) + echo " * BORG_CHOWN=always: chowning ${data_dir} recursively..." + chown -R "${uid}:${gid}" "$data_dir" + ;; + auto) + if [[ "$(stat -c '%u:%g' "$data_dir")" != "${uid}:${gid}" ]] ; then + echo " * ${data_dir} is not owned by borg (${uid}:${gid}), running a one-time recursive chown..." + chown -R "${uid}:${gid}" "$data_dir" + fi + ;; + esac +} + +main() { + set -euo pipefail + local dir error + + usermod -o -u "$PUID" borg &>/dev/null || true + groupmod -o -g "$PGID" borg &>/dev/null || true + + # shellcheck disable=SC1091 + source /etc/os-release + echo "########################################################" + echo -n " * Docker BorgServer powered by " + "$BORG_BIN" -V + echo " * Based on ${PRETTY_NAME}" + echo "########################################################" + echo " * User id: $(id -u borg)" + echo " * Group id: $(id -g borg)" + echo "########################################################" + + if [[ "$BORG_APPEND_ONLY" == "yes" && -z "$BORG_ADMIN" ]] ; then + echo "WARNING: BORG_APPEND_ONLY is active, but no BORG_ADMIN was specified!" fi -done - -echo "########################################################" -echo " * Starting SSH-Key import..." - -# Add every key to borg-users authorized_keys -rm ${AUTHORIZED_KEYS_PATH} &>/dev/null -for keyfile in $(find "${SSH_KEY_DIR}/clients" ! -regex '.*/\..*' -a -type f); do - client_name=$(basename ${keyfile}) - mkdir ${BORG_DATA_DIR}/${client_name} 2>/dev/null - echo " ** Adding client ${client_name} with repo path ${BORG_DATA_DIR}/${client_name}" - - # If client is $BORG_ADMIN unset $client_name, so path restriction equals $BORG_DATA_DIR - # Otherwise add --append-only, if enabled - borg_cmd=${BORG_CMD} - if [ "${client_name}" == "${BORG_ADMIN}" ] ; then - echo " ** Client '${client_name}' is BORG_ADMIN! **" - unset client_name - elif [ "${BORG_APPEND_ONLY}" == "yes" ] ; then - borg_cmd="${BORG_CMD} --append-only" + + for dir in "$BORG_DATA_DIR" "$SSH_KEY_DIR" "${SSH_KEY_DIR}/clients" ; do + echo " * Testing directory: ${dir}" + if [[ ! -d "$dir" ]] ; then + echo "ERROR: ${dir} is no directory!" >&2 + exit 1 + fi + done + + echo " * Checking / Preparing SSH Host-Keys..." + generate_host_keys "$SSH_KEY_DIR" + + echo "########################################################" + echo " * Starting SSH-Key import..." + if ! import_client_keys "${SSH_KEY_DIR}/clients" "$BORG_DATA_DIR" "$AUTHORIZED_KEYS_PATH" ; then + echo "ERROR: No usable SSH pubkey file found in ${SSH_KEY_DIR}/clients" >&2 + exit 1 fi - echo -n "restrict,command=\"$(eval echo -n \"${borg_cmd}\")\" " >> ${AUTHORIZED_KEYS_PATH} - cat ${keyfile} >> ${AUTHORIZED_KEYS_PATH} - echo >> ${AUTHORIZED_KEYS_PATH} -done -chmod 0600 "${AUTHORIZED_KEYS_PATH}" - -echo " * Validating structure of generated ${AUTHORIZED_KEYS_PATH}..." -ERROR=$(ssh-keygen -lf ${AUTHORIZED_KEYS_PATH} 2>&1 >/dev/null) -if [ $? -ne 0 ]; then - echo "ERROR: ${ERROR}" - exit 1 -fi + echo " * Validating structure of generated ${AUTHORIZED_KEYS_PATH}..." + if ! error="$(ssh-keygen -lf "$AUTHORIZED_KEYS_PATH" 2>&1 >/dev/null)" ; then + echo "ERROR: ${error}" >&2 + exit 1 + fi -chown -R borg:borg ${BORG_DATA_DIR} -chown borg:borg ${AUTHORIZED_KEYS_PATH} -chmod 600 ${AUTHORIZED_KEYS_PATH} + chown_borg "$AUTHORIZED_KEYS_PATH" + chmod 0600 "$AUTHORIZED_KEYS_PATH" + sync_ownership "$BORG_DATA_DIR" -echo "########################################################" -echo " * Init done! Starting SSH-Daemon..." + echo "########################################################" + echo " * Init done! Starting SSH-Daemon..." + # exec so sshd becomes PID 1 and receives SIGTERM from `docker stop`; + # without it, in-flight backups are killed by the post-timeout SIGKILL. + exec /usr/sbin/sshd -D -e +} -/usr/sbin/sshd -D -e +if [[ "${BASH_SOURCE[0]}" == "${0}" ]] ; then + main "$@" +fi diff --git a/data/sshd_config b/data/sshd_config index 29e39c3..d91e5ba 100644 --- a/data/sshd_config +++ b/data/sshd_config @@ -3,28 +3,37 @@ AddressFamily any ListenAddress 0.0.0.0 ListenAddress :: -HostKey /sshkeys/host/ssh_host_rsa_key HostKey /sshkeys/host/ssh_host_ed25519_key +HostKey /sshkeys/host/ssh_host_rsa_key +# Only the borg user, only public keys, only the forced command in +# authorized_keys. Nothing else is reachable over this daemon. +AllowUsers borg PermitRootLogin no StrictModes yes -MaxSessions 20 PubkeyAuthentication yes AuthorizedKeysFile .ssh/authorized_keys +PasswordAuthentication no +PermitEmptyPasswords no +KbdInteractiveAuthentication no +GSSAPIAuthentication no +UsePAM no -LogLevel INFO +LoginGraceTime 30 +MaxAuthTries 3 +MaxSessions 20 +MaxStartups 10:30:60 -PasswordAuthentication no -ChallengeResponseAuthentication no -UsePAM yes -AllowAgentForwarding no -AllowTcpForwarding no -X11Forwarding no +KexAlgorithms sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com +MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com + +DisableForwarding yes PermitTTY no PrintMotd no -PermitTunnel no -Subsystem sftp /bin/false + +LogLevel INFO ClientAliveInterval 10 ClientAliveCountMax 30 diff --git a/docker-compose.yml b/docker-compose.yml index e66b4cd..bdf192f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: '3' services: borgserver: - image: nold360/borgserver + image: ghcr.io/ls1admin/borgserver:latest #build: . volumes: - ./backup:/backup @@ -19,3 +19,30 @@ services: # Filename of Admins SSH-Key; has full access to all repos BORG_ADMIN: "" restart: unless-stopped + labels: + # Opts this container in to the watchtower updater below. + com.centurylinklabs.watchtower.enable: "true" + + # Optional: pull a new borgserver image every night and restart the container + # only if the digest actually changed. Uncomment to enable. + # + # The image is rebuilt daily but only republished when the base image, a + # package in it, or this repository changed, so most nights this is a no-op + # and the container is left running untouched. + # + # NOTE: mounting the docker socket gives this container root-equivalent + # control of the host. If you would rather not do that on a backup server, + # see the host-cron alternative in the README instead. + # + # watchtower: + # image: containrrr/watchtower:latest + # volumes: + # - /var/run/docker.sock:/var/run/docker.sock + # environment: + # # The schedule below is read in this timezone; the default is UTC. + # TZ: "Europe/Berlin" + # # Six-field cron (the leading field is seconds): 04:00 every night. + # # Pick a window that cannot overlap your clients' backup runs - an update + # # restarts the container and drops any backup that is in flight. + # command: --label-enable --cleanup --schedule "0 0 4 * * *" + # restart: unless-stopped diff --git a/tests/integration_test.sh b/tests/integration_test.sh new file mode 100755 index 0000000..cea8b58 --- /dev/null +++ b/tests/integration_test.sh @@ -0,0 +1,156 @@ +#!/bin/bash +# End-to-end smoke test: start the built image, drive a version-matched borg +# client against it over ssh, and assert the hardening behaviours. +# +# Run: IMAGE=borgserver:test-1.4 BORG_PACKAGE=borgbackup BORG_SERIES=1.4 \ +# bash tests/integration_test.sh +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +# shellcheck source=tests/lib.sh +source "${HERE}/lib.sh" + +IMAGE="${IMAGE:-borgserver:test}" +BORG_PACKAGE="${BORG_PACKAGE:-borgbackup}" +BORG_SERIES="${BORG_SERIES:-1.4}" +BASE_IMAGE="${BASE_IMAGE:-debian:trixie-slim}" + +SUFFIX="$$" +NET="borgtest-net-${SUFFIX}" +SRV="borgtest-srv-${SUFFIX}" +SRV_AO="borgtest-srv-ao-${SUFFIX}" +WORK="$(mktemp -d)" +chmod 0755 "$WORK" + +cleanup() { + docker rm -f "$SRV" "$SRV_AO" >/dev/null 2>&1 + docker network rm "$NET" >/dev/null 2>&1 + docker run --rm -v "${WORK}:/work" --entrypoint chown "$IMAGE" \ + -R "$(id -u):$(id -g)" /work >/dev/null 2>&1 || true + rm -rf "$WORK" +} +trap cleanup EXIT + +echo "== fixture ==" +mkdir -p "${WORK}/sshkeys/clients" "${WORK}/backup" +ssh-keygen -q -t ed25519 -N '' -f "${WORK}/client_key" +ssh-keygen -q -t ed25519 -N '' -f "${WORK}/other_key" +cp "${WORK}/client_key.pub" "${WORK}/sshkeys/clients/testclient" +# A filename that would be executed by a shell if the name were interpolated +# into a command: the container must skip it, not run it. The name must +# contain no slash, or `cp` fails and the test proves nothing. run.sh runs +# with cwd `/` in the container, so an injected touch lands at /pwned_marker. +cp "${WORK}/client_key.pub" "${WORK}/sshkeys/clients/\$(touch pwned_marker)" +# A file with a second key must contribute at most one restricted entry. +cat "${WORK}/other_key.pub" "${WORK}/client_key.pub" > "${WORK}/sshkeys/clients/twokeys" + +docker network create "$NET" >/dev/null +docker run -d --name "$SRV" --network "$NET" \ + -v "${WORK}/sshkeys:/sshkeys" -v "${WORK}/backup:/backup" "$IMAGE" >/dev/null + +echo "== startup ==" +ready=1 +for _ in $(seq 1 60) ; do + if docker exec "$SRV" bash -c 'exec 3<>/dev/tcp/127.0.0.1/22' >/dev/null 2>&1 ; then + ready=0 + break + fi + sleep 1 +done +if [[ $ready -ne 0 ]] ; then + echo "server did not come up; logs:" + docker logs "$SRV" +fi +assert_eq "0" "$ready" "sshd accepts connections" + +assert_ok "sshd validates its own config" \ + docker exec "$SRV" /usr/sbin/sshd -t + +assert_eq '*' "$(docker exec "$SRV" sh -c 'getent shadow borg | cut -d: -f2')" \ + "the borg account is not locked (sshd rejects '!'-prefixed shadow entries when UsePAM is off)" + +assert_ok "the hostile client filename was not executed" \ + docker exec "$SRV" test ! -e /pwned_marker + +AK="$(docker exec "$SRV" cat /home/borg/.ssh/authorized_keys)" +assert_eq "2" "$(printf '%s\n' "$AK" | grep -c 'restrict,command=')" \ + "exactly two clients imported (hostile filename skipped)" +assert_eq "0" "$(printf '%s\n' "$AK" | grep -cv 'restrict,command=')" \ + "every authorized_keys line carries a forced command" +assert_contains "$AK" "--restrict-to-path /backup/testclient" "testclient is path-restricted" + +echo "== borg round trip ==" +if [[ "$BORG_SERIES" == 2.* ]] ; then + BORG_CLIENT_SCRIPT='borg2 repo-create --encryption=none && borg2 create smoke /etc/hostname && borg2 repo-list' + # borg 2.x reads a single-slash path as relative to the forced command's + # cwd; a double slash is what means "absolute" there. borg 1.x treats a + # single slash as absolute already. + BORG_REPO_URL="ssh://borg@${SRV}//backup/testclient/smokerepo" +else + BORG_CLIENT_SCRIPT='borg init -e none && borg create ::smoke /etc/hostname && borg list' + BORG_REPO_URL="ssh://borg@${SRV}/backup/testclient/smokerepo" +fi + +CLIENT_OUT="$(docker run --rm --network "$NET" -v "${WORK}:/work" \ + -e DEBIAN_FRONTEND=noninteractive \ + -e BORG_REPO="${BORG_REPO_URL}" \ + -e BORG_RSH="ssh -i /work/client_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" \ + -e BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK=yes \ + -e BORG_RELOCATED_REPO_ACCESS_IS_OK=yes \ + "$BASE_IMAGE" bash -c " + apt-get update -qq >/dev/null 2>&1 && + apt-get install -y -qq --no-install-recommends ${BORG_PACKAGE} openssh-client >/dev/null 2>&1 && + chmod 600 /work/client_key && + ${BORG_CLIENT_SCRIPT}" 2>&1)" +CLIENT_RC=$? +if [[ $CLIENT_RC -ne 0 ]] ; then + echo "client output:" + printf '%s\n' "$CLIENT_OUT" +fi +assert_eq "0" "$CLIENT_RC" "borg init + create + list round trip succeeds" +assert_contains "$CLIENT_OUT" "smoke" "the created archive is listed" +assert_ok "the repository landed in the client's own directory" \ + test -d "${WORK}/backup/testclient/smokerepo" + +echo "== append-only mapping ==" +docker run -d --name "$SRV_AO" --network "$NET" \ + -e BORG_APPEND_ONLY=yes -e BORG_ADMIN=testclient \ + -v "${WORK}/sshkeys:/sshkeys" -v "${WORK}/backup:/backup" "$IMAGE" >/dev/null +ready_ao=1 +for _ in $(seq 1 60) ; do + if docker exec "$SRV_AO" bash -c 'exec 3<>/dev/tcp/127.0.0.1/22' >/dev/null 2>&1 ; then + ready_ao=0 + break + fi + sleep 1 +done +if [[ $ready_ao -ne 0 ]] ; then + echo "append-only server did not come up; logs:" + docker logs "$SRV_AO" +fi +assert_eq "0" "$ready_ao" "append-only sshd accepts connections" + +AK_AO="$(docker exec "$SRV_AO" cat /home/borg/.ssh/authorized_keys)" +if [[ "$BORG_SERIES" == 2.* ]] ; then + assert_contains "$AK_AO" "--permissions=no-delete" "2.x maps append-only to --permissions=no-delete" + assert_not_contains "$AK_AO" "--append-only" "2.x never emits --append-only" +else + assert_contains "$AK_AO" "--append-only" "1.x emits --append-only" +fi +ADMIN_AK="$(printf '%s\n' "$AK_AO" | grep -F "$(awk '{print $2}' "${WORK}/client_key.pub")")" +assert_not_contains "$ADMIN_AK" "no-delete" "the BORG_ADMIN key is not restricted (no-delete)" +assert_not_contains "$ADMIN_AK" "--append-only" "the BORG_ADMIN key is not restricted (append-only)" + +echo "== signal handling ==" +START="$(date +%s)" +docker stop --time 30 "$SRV" >/dev/null +ELAPSED=$(( $(date +%s) - START )) +if [[ $ELAPSED -lt 10 ]] ; then + _pass "docker stop terminates promptly via SIGTERM (${ELAPSED}s)" +else + _fail "docker stop terminates promptly via SIGTERM" \ + "took ${ELAPSED}s, so sshd is not PID 1 and the container was SIGKILLed" +fi + +finish diff --git a/tests/lib.sh b/tests/lib.sh new file mode 100644 index 0000000..133a6af --- /dev/null +++ b/tests/lib.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# Minimal assertion helpers for the borgserver shell tests. +# Sourced by tests/unit_run_sh.sh and tests/integration_test.sh. + +TESTS_RUN=0 +TESTS_FAILED=0 + +_pass() { + TESTS_RUN=$((TESTS_RUN + 1)) + printf ' ok %s\n' "$1" +} + +_fail() { + TESTS_RUN=$((TESTS_RUN + 1)) + TESTS_FAILED=$((TESTS_FAILED + 1)) + printf ' FAIL %s\n' "$1" + shift + local detail + for detail in "$@" ; do + printf ' %s\n' "$detail" + done +} + +# assert_eq +assert_eq() { + if [[ "$1" == "$2" ]] ; then + _pass "$3" + else + _fail "$3" "expected: $1" "actual: $2" + fi +} + +# assert_contains +assert_contains() { + if [[ "$1" == *"$2"* ]] ; then + _pass "$3" + else + _fail "$3" "expected to contain: $2" "actual: $1" + fi +} + +# assert_not_contains +assert_not_contains() { + if [[ "$1" != *"$2"* ]] ; then + _pass "$3" + else + _fail "$3" "expected NOT to contain: $2" "actual: $1" + fi +} + +# assert_ok +assert_ok() { + local message="$1" + shift + if "$@" >/dev/null 2>&1 ; then + _pass "$message" + else + _fail "$message" "command failed: $*" + fi +} + +# assert_fails +assert_fails() { + local message="$1" + shift + if "$@" >/dev/null 2>&1 ; then + _fail "$message" "command unexpectedly succeeded: $*" + else + _pass "$message" + fi +} + +# assert_file_missing +assert_file_missing() { + if [[ ! -e "$1" ]] ; then + _pass "$2" + else + _fail "$2" "file exists but should not: $1" + fi +} + +finish() { + printf '\n%d checks, %d failed\n' "$TESTS_RUN" "$TESTS_FAILED" + [[ $TESTS_FAILED -eq 0 ]] +} diff --git a/tests/unit_run_sh.sh b/tests/unit_run_sh.sh new file mode 100644 index 0000000..62c0fa6 --- /dev/null +++ b/tests/unit_run_sh.sh @@ -0,0 +1,181 @@ +#!/bin/bash +# Unit tests for the pure helpers in data/run.sh. No Docker required. +# Run: bash tests/unit_run_sh.sh +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +# shellcheck source=tests/lib.sh +source "${HERE}/lib.sh" +# shellcheck disable=SC1091 +# shellcheck source=data/run.sh +source "${HERE}/../data/run.sh" + +TMP="$(mktemp -d)" +trap 'rm -rf "${TMP}" "${PWD}/pwned_marker"' EXIT + +ssh-keygen -q -t ed25519 -N '' -f "${TMP}/key_a" +ssh-keygen -q -t ed25519 -N '' -f "${TMP}/key_b" +KEY_A="$(cat "${TMP}/key_a.pub")" +KEY_B="$(cat "${TMP}/key_b.pub")" + +echo "== is_valid_client_name ==" +assert_ok "accepts a hostname" is_valid_client_name "web01.example.com" +assert_ok "accepts underscores and dashes" is_valid_client_name "host_1-2" +# shellcheck disable=SC2016 +assert_fails "rejects command substitution" is_valid_client_name '$(touch /pwned)' +# shellcheck disable=SC2016 +assert_fails "rejects backticks" is_valid_client_name 'a`id`b' +assert_fails "rejects path traversal" is_valid_client_name "../etc" +assert_fails "rejects dotdot" is_valid_client_name ".." +assert_fails "rejects dot" is_valid_client_name "." +assert_fails "rejects spaces" is_valid_client_name "two words" +assert_fails "rejects semicolons" is_valid_client_name "a;id" +assert_fails "rejects the empty string" is_valid_client_name "" + +echo "== extract_first_key ==" +printf '%s\n' "$KEY_A" > "${TMP}/one_key" +assert_eq "$KEY_A" "$(extract_first_key "${TMP}/one_key")" "returns the only key" + +printf '\n# a comment\n\n%s\n' "$KEY_A" > "${TMP}/comments" +assert_eq "$KEY_A" "$(extract_first_key "${TMP}/comments")" "skips blanks and comments" + +printf '%s\n%s\n' "$KEY_A" "$KEY_B" > "${TMP}/two_keys" +assert_eq "$KEY_A" "$(extract_first_key "${TMP}/two_keys")" "returns only the first of two keys" + +printf 'command="/bin/sh" %s\n' "$KEY_A" > "${TMP}/with_options" +assert_fails "rejects a key line carrying its own options" extract_first_key "${TMP}/with_options" + +: > "${TMP}/empty" +assert_fails "rejects an empty file" extract_first_key "${TMP}/empty" + +printf 'not a key at all\n' > "${TMP}/garbage" +assert_fails "rejects garbage" extract_first_key "${TMP}/garbage" + +echo "== is_valid_public_key ==" +assert_ok "accepts a real key" is_valid_public_key "$KEY_A" +assert_fails "rejects a fake key" is_valid_public_key "ssh-ed25519 AAAAnotbase64 broken" + +echo "== append_only_flag ==" +assert_eq "--append-only" "$(append_only_flag 1.4)" "1.4 uses --append-only" +assert_eq "--permissions=no-delete" "$(append_only_flag 2.0)" "2.0 uses --permissions=no-delete" + +echo "== escape_authorized_keys_value ==" +assert_eq 'a\"b' "$(escape_authorized_keys_value 'a"b')" "escapes double quotes" +assert_eq 'a\\b' "$(escape_authorized_keys_value 'a\b')" "escapes backslashes" + +echo "== build_forced_command ==" +CMD="$(build_forced_command "/backup/web01" "no" "1.4" "" "/usr/bin/borg")" +assert_contains "$CMD" "cd /backup/web01;" "changes into the repo path" +assert_contains "$CMD" "/usr/bin/borg serve" "invokes the configured borg binary" +assert_contains "$CMD" "--restrict-to-path /backup/web01" "restricts to the repo path" +assert_not_contains "$CMD" "--append-only" "no append-only unless requested" + +CMD="$(build_forced_command "/backup/web01" "yes" "1.4" "" "/usr/bin/borg")" +assert_contains "$CMD" "--append-only" "1.4 append-only flag" + +CMD="$(build_forced_command "/backup/web01" "yes" "2.0" "" "/usr/bin/borg2")" +assert_contains "$CMD" "/usr/bin/borg2 serve" "2.0 uses borg2" +assert_contains "$CMD" "--permissions=no-delete" "2.0 append-only equivalent" +assert_not_contains "$CMD" "--append-only" "2.0 does not use --append-only" + +CMD="$(build_forced_command "/backup/web01" "no" "1.4" "--progress --debug" "/usr/bin/borg")" +assert_contains "$CMD" "--progress --debug" "passes BORG_SERVE_ARGS through" + +echo "== authorized_keys_line ==" +LINE="$(authorized_keys_line 'cd /backup/x; borg serve' "$KEY_A")" +assert_contains "$LINE" 'restrict,command="cd /backup/x; borg serve" ' "emits restrict + forced command" +assert_contains "$LINE" "$KEY_A" "appends the key" + +echo "== import_client_keys ==" +KEYDIR="${TMP}/clients" +DATADIR="${TMP}/backup" +AKP="${TMP}/authorized_keys" +mkdir -p "$KEYDIR" "$DATADIR" + +printf '%s\n' "$KEY_A" > "${KEYDIR}/web01" +printf '%s\n%s\n' "$KEY_B" "$KEY_A" > "${KEYDIR}/twokeys" +# A filename that IS a command substitution, with no slashes so the redirect +# above can actually create it on disk (a real filename cannot contain '/'). +# The single-quoted literal keeps the test shell from expanding it. +# shellcheck disable=SC2016 +printf '%s\n' "$KEY_A" > "${KEYDIR}/"'$(touch pwned_marker)' +printf '%s\n' "$KEY_A" > "${KEYDIR}/.hidden" +printf 'ssh-ed25519 AAAAnotarealkey nope\n' > "${KEYDIR}/malformed" +# shellcheck disable=SC2016 +assert_ok "the hostile filename literally exists on disk" \ + test -e "${KEYDIR}/"'$(touch pwned_marker)' + +# Set the globals import_client_keys reads on their own lines: assignments +# prefixed to a *function* call are not reliably scoped to that call in bash. +BORG_ADMIN="" +BORG_APPEND_ONLY="no" +BORG_SERIES="1.4" +BORG_BIN="/usr/bin/borg" +BORG_SERVE_ARGS="" +import_client_keys "$KEYDIR" "$DATADIR" "$AKP" > "${TMP}/import.log" 2>&1 +assert_eq "0" "$?" "import succeeds when at least one key is usable" + +AK="$(cat "$AKP")" +# An injected `touch pwned_marker` would run relative to the test process's cwd +# (not $KEYDIR, not $TMP) if the filename were ever passed through a shell. +assert_file_missing "${PWD}/pwned_marker" "a filename containing \$(...) is not executed as a command" +assert_eq "2" "$(grep -c 'restrict,command=' "$AKP")" "only the two well-formed clients are imported" + +# authorized_keys(5): the command= value is executed by the connecting user's +# shell at login time, not by import_client_keys itself. Replaying every +# generated forced command through a real shell -- exactly what sshd does -- +# is what would actually catch a hostile client name that slipped past +# validation and got embedded, unescaped, in a forced command. +while IFS= read -r ak_line ; do + ak_cmd="${ak_line#restrict,command=\"}" + ak_cmd="${ak_cmd%%\" *}" + bash -c "$ak_cmd" >/dev/null 2>&1 || true +done < "$AKP" +assert_file_missing "${PWD}/pwned_marker" "no generated forced command executes an attacker-controlled substitution when run as sshd would run it" +assert_contains "$AK" "--restrict-to-path ${DATADIR}/web01" "web01 is restricted to its own repo" +assert_contains "$AK" "--restrict-to-path ${DATADIR}/twokeys" "twokeys is restricted to its own repo" +assert_eq "1" "$(grep -cF "$KEY_B" "$AKP")" "only the first key of a two-key file is used" +assert_not_contains "$AK" "hidden" "hidden files are ignored" +assert_not_contains "$AK" "malformed" "a malformed key file is skipped" +assert_ok "creates the repo directory" test -d "${DATADIR}/web01" +assert_eq "600" "$(stat -c '%a' "$AKP")" "authorized_keys is mode 0600" + +echo "== import_client_keys: admin and append-only ==" +AKP2="${TMP}/authorized_keys2" +BORG_ADMIN="web01" +BORG_APPEND_ONLY="yes" +import_client_keys "$KEYDIR" "$DATADIR" "$AKP2" >/dev/null 2>&1 +ADMIN_LINE="$(grep -F "$KEY_A" "$AKP2")" +OTHER_LINE="$(grep -F "$KEY_B" "$AKP2")" +# The admin's path is the data dir itself, so the forced command ends there and +# the closing quote of the command= option follows immediately. +assert_contains "$ADMIN_LINE" "--restrict-to-path ${DATADIR}\"" "the admin is restricted to the whole data dir" +assert_not_contains "$ADMIN_LINE" "--append-only" "the admin is never append-only" +assert_contains "$OTHER_LINE" "--append-only" "non-admin clients get --append-only" + +AKP3="${TMP}/authorized_keys3" +BORG_ADMIN="" +BORG_APPEND_ONLY="yes" +BORG_SERIES="2.0" +BORG_BIN="/usr/bin/borg2" +import_client_keys "$KEYDIR" "$DATADIR" "$AKP3" >/dev/null 2>&1 +assert_contains "$(cat "$AKP3")" "--permissions=no-delete" "the 2.0 series uses the permissions flag" +BORG_SERIES="1.4" +BORG_BIN="/usr/bin/borg" +BORG_APPEND_ONLY="no" + +echo "== import_client_keys: nothing usable ==" +EMPTYDIR="${TMP}/empty_clients" +mkdir -p "$EMPTYDIR" +assert_fails "returns non-zero when no key is usable" \ + import_client_keys "$EMPTYDIR" "$DATADIR" "${TMP}/authorized_keys4" + +echo "== sync_ownership ==" +BORG_CHOWN="bogus" +assert_fails "rejects an invalid BORG_CHOWN value" sync_ownership "$DATADIR" +BORG_CHOWN="never" +assert_ok "never is a no-op" sync_ownership "$DATADIR" +BORG_CHOWN="auto" + +finish