From a1c65b3d8186c6661db0d58c3f64603b88669372 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 26 Oct 2026 21:01:40 +0100 Subject: [PATCH] =?UTF-8?q?R-1300:=20SECURITY=20=E2=80=94=20cloudflared=20?= =?UTF-8?q?2026.10.3=20on=20main=20(same=20change=20as=20tag=20v0.345.1);?= =?UTF-8?q?=20build.sh=20may=20push=20from=20the=20pushed=20tag=20v?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit infra.CloudflaredImage 2026.9.3 -> 2026.10.3 (Cloudflare's eMudhra notice, 2026-10-20) and TestEnsureCloudflared_ANewImageRecreatesTheTunnel (red-proved). build.sh: a --push may build from the tag v as the server has it, pointing at HEAD (operator's yes 2026-10-26); four new cases in test_build_push_once.sh, red-proved both ways. MinAgent 0.158.1 unchanged. --- CHANGELOG.md | 21 +++++++++++++ controller/build.sh | 25 ++++++++++++---- controller/internal/infra/infra.go | 6 ++-- .../internal/stacks/infra_tunnel_test.go | 30 +++++++++++++++++++ controller/scripts/test_build_push_once.sh | 22 ++++++++++++++ 5 files changed, 96 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a67d205f..9e85e7ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,27 @@ hub answers 400 to both and nothing else changes. down, a changed reason, the wire reason, the wiring in main, the record across a reload. Red-proved with an inert `Tick` (all six behaviour tests failed) and with the scheduler line commented out (the wiring test failed). +## v0.345.1 (2026-10-26) — SECURITY: cloudflared 2026.10.3 (Cloudflare's eMudhra notice) — built from v0.345.0 + one commit (R-1300) + +**MinAgent: 0.158.1** (unchanged). **This entry on `main` records the same change as the tag `v0.345.1`** (the v0.345.0 +commit `6bfb784` + one commit); the image is built from the TAG, so none of the „Unreleased" work above is in it — the +next release from `main` carries the bump by itself. + +- **Security:** `infra.CloudflaredImage` `cloudflare/cloudflared:2026.9.3` → `2026.10.3` (Docker Hub index digest + `sha256:faa8e0a1475f137c910be6025f43c8e4092fdd35461b31e19fd1390657a1ff39`). Cloudflare's notice of 2026-10-20: + cloudflared before 2026.10.2 trusts eight eMudhra (emSign) intermediate CAs whose private keys were weak and are + recoverable — anyone holding them could pose as any website to cloudflared. The fix is the image's trust store. +- After the controller update `ensureCloudflared` sees the compose differ (the `image:` line) and runs `compose up -d`, + which recreates cloudflared on the new image. Test `TestEnsureCloudflared_ANewImageRecreatesTheTunnel`, red-proved. +- Origin check (read only): every tunnel routes `*.` → `https://traefik` with `noTLSVerify: true` (R-510) and + traefik answers with its self-signed default certificate — no eMudhra chain. felhom.eu + `documentation/audits/cloudflared-2026.10.3-2026-10-26/`. +- **`build.sh` (main only — the operator's yes, 2026-10-26):** a `--push` may also build from the tag `v` AS + THE SERVER HAS IT when it points at HEAD — a patch release off a released commit while `main` holds unreleased work. + A tag only on the clone, a tag on another commit, a tag of another version, or a server that cannot be asked is a + refusal; clean-tree stays required. Four new cases in `scripts/test_build_push_once.sh` (20/20), red-proved both + ways (the tag branch accepting anything: 4 fail; removed: 1 fails). + ## v0.345.0 (2026-10-26) — the 50 % rule owns qBittorrent's NORMAL limit only (R-1292) — attended release run (afternoon) Live before the release: Tester 1 on `0.345.0-rc.1` with a throwaway qBittorrent, alternative speed ON (the household's diff --git a/controller/build.sh b/controller/build.sh index 0d13d7a9..5f7bafba 100755 --- a/controller/build.sh +++ b/controller/build.sh @@ -153,14 +153,27 @@ if [[ "${ACTION}" == "--push" || "${ACTION}" == "--multiarch" ]]; then fi head_sha=$(cd "${REPO_DIR}" && git rev-parse HEAD 2>/dev/null || echo unknown) main_sha=$(cd "${REPO_DIR}" && git rev-parse origin/main 2>/dev/null || echo unknown) + # The one other source that can be fetched again: the tag v AS THE SERVER HAS IT, pointing at HEAD. That + # is a patch release built off a released version while main holds unreleased work (v0.345.1, R-1300, cloudflared's + # security bump, 2026-10-26 — operator's yes in that session). Only the tag that names THIS version counts; a tag + # only on this clone, a tag on another commit, or a server that cannot be asked is a refusal. Pinned by + # scripts/test_build_push_once.sh („tag" cases). if [[ "${head_sha}" != "${main_sha}" ]]; then - error "${REPO_DIR} is not at origin/main — refusing to push." - error " HEAD ${head_sha}" - error " origin/main ${main_sha}" - error "An unpushed change does not exist; an image built from one cannot be fetched again." - exit 1 + tag_sha=$(cd "${REPO_DIR}" && GIT_TERMINAL_PROMPT=0 git ls-remote origin "refs/tags/v${VERSION}" "refs/tags/v${VERSION}^{}" 2>/dev/null \ + | awk '$2 ~ /\^\{\}$/ {peeled=$1} $2 !~ /\^\{\}$/ {plain=$1} END {print (peeled != "" ? peeled : plain)}') || tag_sha="" # no origin / unreachable: the error below, not set -e + if [[ -n "${tag_sha}" && "${tag_sha}" == "${head_sha}" ]]; then + info "Source tree is clean and at the pushed tag v${VERSION} (not origin/main)." + else + error "${REPO_DIR} is not at origin/main, nor at a pushed tag v${VERSION} — refusing to push." + error " HEAD ${head_sha}" + error " origin/main ${main_sha}" + error " v${VERSION} on origin ${tag_sha:-}" + error "An unpushed change does not exist; an image built from one cannot be fetched again." + exit 1 + fi + else + info "Source tree is clean and at origin/main." fi - info "Source tree is clean and at origin/main." else error "${REPO_DIR} is not a git checkout — refusing to push a build whose source cannot be identified." exit 1 diff --git a/controller/internal/infra/infra.go b/controller/internal/infra/infra.go index 1cae8117..45ebfc42 100644 --- a/controller/internal/infra/infra.go +++ b/controller/internal/infra/infra.go @@ -20,10 +20,12 @@ import ( ) // Pinned image tags — NEVER ":latest" (a floating tag breaks reproducible golden bakes and lets the -// deployed version drift). Verified to resolve on Docker Hub before baking. +// deployed version drift). Verified to resolve on Docker Hub before baking. cloudflared follows Cloudflare's security +// notices (felhom.eu `01` „Tunnel placement"): 2026.10.3 since the notice of 2026-10-20 — before 2026.10.2 the image +// trusts eight eMudhra (emSign) intermediates with weak keys. const ( TraefikImage = "traefik:v3.7.13" - CloudflaredImage = "cloudflare/cloudflared:2026.9.3" + CloudflaredImage = "cloudflare/cloudflared:2026.10.3" FileBrowserImage = "gtstef/filebrowser:1.5.6-stable" // FileBrowserImportMount is the in-container mount point NAME for the canonical drop-zone // (R-75): the bind lands at /srv/. ASCII and space-free on purpose — it appears in a diff --git a/controller/internal/stacks/infra_tunnel_test.go b/controller/internal/stacks/infra_tunnel_test.go index 041b8697..7ce4d603 100644 --- a/controller/internal/stacks/infra_tunnel_test.go +++ b/controller/internal/stacks/infra_tunnel_test.go @@ -326,3 +326,33 @@ func TestEnsureForwardedHeaders_R1033_RewritesAnOldFile(t *testing.T) { t.Fatal("the rewritten file must carry the https-only redirect") } } + +// A controller update that changes only cloudflared's IMAGE recreates the running container on the new image (the +// 2026.10.3 security bump rides this: no other step moves a household's tunnel). The control: the same render again +// is a no-op. +func TestEnsureCloudflared_ANewImageRecreatesTheTunnel(t *testing.T) { + state := stubDocker(t) + touch(t, filepath.Join(state, "running-cloudflared"), "") + m, calls := tunnelTestManager(t, "") + dir := t.TempDir() + cur, _ := infra.RenderCloudflared(infra.CloudflaredData{CFTunnelToken: "tok-test", Tunnel: true}) + old := strings.Replace(cur["docker-compose.yml"].Content, "image: "+infra.CloudflaredImage, "image: cloudflare/cloudflared:2026.9.3", 1) + if old == cur["docker-compose.yml"].Content { + t.Fatal("the render has no `image:` line to age — the test would prove nothing") + } + touch(t, filepath.Join(dir, "docker-compose.yml"), old) + + if err := m.ensureCloudflared(dir, true); err != nil { + t.Fatalf("ensureCloudflared: %v", err) + } + cmp, _ := os.ReadFile(filepath.Join(dir, "docker-compose.yml")) + if !strings.Contains(string(cmp), "image: "+infra.CloudflaredImage) { + t.Fatalf("the compose must carry %s:\n%s", infra.CloudflaredImage, cmp) + } + if len(*calls) != 1 || (*calls)[0].args != "up -d" { + t.Fatalf("want one `up -d` (compose recreates on the new image), got %+v", *calls) + } + if err := m.ensureCloudflared(dir, true); err != nil || len(*calls) != 1 { + t.Fatalf("an unchanged image must not recreate the tunnel again; err %v calls %+v", err, *calls) + } +} diff --git a/controller/scripts/test_build_push_once.sh b/controller/scripts/test_build_push_once.sh index d211cc62..3bfec27a 100644 --- a/controller/scripts/test_build_push_once.sh +++ b/controller/scripts/test_build_push_once.sh @@ -141,6 +141,28 @@ run_build_in absent "$CLEAN_REPO" 0.999.0 --push check "ahead of origin/main -> refuses" 1 "not at origin/main" "FAKE-DOCKER-REACHED" rm -rf "$CLEAN_REPO" +# ── OR AT THE PUSHED TAG v (v0.345.1, 2026-10-26) ─────────────────────────────────────── +# A patch release off a released commit while main holds unreleased work. The tag must be on the SERVER, name THIS version, +# and point at HEAD. RED-PROOF: with the tag branch accepting any tag the three refusals fail; with it removed the +# first case fails. +TAG_REPO="$(scratch_repo)"; TAG_ORIGIN="$(mktemp -d)" +git init -q --bare "$TAG_ORIGIN" 2>/dev/null; git -C "$TAG_REPO" remote add origin "$TAG_ORIGIN" +echo y > "$TAG_REPO/controller/f2" +git -C "$TAG_REPO" add -A >/dev/null; git -C "$TAG_REPO" commit -qm fix >/dev/null +git -C "$TAG_REPO" tag -a v0.999.1 -m t; git -C "$TAG_REPO" push -q origin v0.999.1 2>/dev/null +run_build_in absent "$TAG_REPO" 0.999.1 --push +check "at the pushed tag v -> proceeds" "$rc" "at the pushed tag v0.999.1" "refusing to push" +run_build_in absent "$TAG_REPO" 0.999.2 --push +check "a pushed tag of ANOTHER version -> refuses" 1 "nor at a pushed tag v0.999.2" "FAKE-DOCKER-REACHED" +git -C "$TAG_REPO" tag v0.999.3 +run_build_in absent "$TAG_REPO" 0.999.3 --push +check "a tag only on this clone -> refuses" 1 "nor at a pushed tag v0.999.3" "FAKE-DOCKER-REACHED" +echo z > "$TAG_REPO/controller/f3" +git -C "$TAG_REPO" add -A >/dev/null; git -C "$TAG_REPO" commit -qm later >/dev/null +run_build_in absent "$TAG_REPO" 0.999.1 --push +check "the pushed tag on another commit -> refuses" 1 "nor at a pushed tag v0.999.1" "FAKE-DOCKER-REACHED" +rm -rf "$TAG_REPO" "$TAG_ORIGIN" + NOT_A_REPO="$(mktemp -d)"; mkdir -p "$NOT_A_REPO/controller" run_build_in absent "$NOT_A_REPO" 0.999.0 --push check "not a git checkout -> refuses" 1 "not a git checkout" "FAKE-DOCKER-REACHED"