From a0ce923690a94a419350ea8e7a07c74f212ce94d Mon Sep 17 00:00:00 2001 From: Jorge Castro Date: Sat, 18 Jul 2026 16:41:37 -0400 Subject: [PATCH 1/3] feat(telemetry): add Dakota countme client Add a lightweight weekly telemetry client for Dakota using systemd DynamicUser and curl, plus the related image-registry guidance. Assisted-by: MAI-Code-1-Flash via GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/skills/image-registry.md | 9 +-- .../system-preset/03-dakota-countme.preset | 1 + .../lib/systemd/system/dakota-countme.service | 10 +++ .../lib/systemd/system/dakota-countme.timer | 14 ++++ .../shared/usr/libexec/dakota-countme | 68 +++++++++++++++++++ 5 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 system_files/shared/usr/lib/systemd/system-preset/03-dakota-countme.preset create mode 100644 system_files/shared/usr/lib/systemd/system/dakota-countme.service create mode 100644 system_files/shared/usr/lib/systemd/system/dakota-countme.timer create mode 100755 system_files/shared/usr/libexec/dakota-countme diff --git a/docs/skills/image-registry.md b/docs/skills/image-registry.md index 18e713fba..13a2ba6f2 100644 --- a/docs/skills/image-registry.md +++ b/docs/skills/image-registry.md @@ -101,11 +101,12 @@ The Containerfile pulls wallpaper artwork from `ghcr.io/ublue-os/bluefin-wallpap ## CountMe telemetry reporting -Our images participate in Fedora's weekly CountMe telemetry to track installation statistics anonymously: +Our images participate in weekly CountMe telemetry to track installation statistics anonymously: - **Bluefin & Bluefin LTS:** Handled by standard repository configuration, and since CentOS-based bootc images are broken with legacy rpm-ostree countme, they use a dnf5-based helper service. -- **Dakota:** Since it is based on GNOME OS and has no standard rpm-ostree/dnf packages, it utilizes a custom weekly systemd service/timer (`bluefin-countme.timer` triggering `/usr/libexec/dakota-countme`). - - It generates and maintains an installation epoch cookie in `/var/lib/dakota-countme-epoch` to mimic Fedora's week-based age buckets. - - It performs a weekly query to Fedora's metalink using a `libdnf5`-format User Agent with `os_name="Dakota"` (e.g. `libdnf5/5.2.9 (Dakota;${VERSION_ID};${ARCH}) hawkey`). +- **Dakota:** Since it is based on GNOME OS and has no standard rpm-ostree/dnf packages, it uses a production-grade client-server implementation: + - **Systemd units:** `dakota-countme.timer` and `dakota-countme.service`, centralized in `common/system_files/shared/`, trigger `/usr/libexec/dakota-countme`. + - **State directory:** Uses a secure systemd `StateDirectory=/var/lib/dakota-countme/` with `DynamicUser=yes`; the `epoch` and `lastrun` files are stored there to track installation age buckets. + - **Server & request format:** Queries our custom Cloudflare Worker at `https://countme.projectbluefin.io` with query parameters `?repo=${IMAGE_NAME}&tag=${IMAGE_TAG}&flavor=${IMAGE_FLAVOR}&arch=${ARCH}&countme=${BUCKET}` and a `dakota-countme` User-Agent. ### Dashboard processing dependency diff --git a/system_files/shared/usr/lib/systemd/system-preset/03-dakota-countme.preset b/system_files/shared/usr/lib/systemd/system-preset/03-dakota-countme.preset new file mode 100644 index 000000000..0554d0f4d --- /dev/null +++ b/system_files/shared/usr/lib/systemd/system-preset/03-dakota-countme.preset @@ -0,0 +1 @@ +enable dakota-countme.timer diff --git a/system_files/shared/usr/lib/systemd/system/dakota-countme.service b/system_files/shared/usr/lib/systemd/system/dakota-countme.service new file mode 100644 index 000000000..95645cafc --- /dev/null +++ b/system_files/shared/usr/lib/systemd/system/dakota-countme.service @@ -0,0 +1,10 @@ +[Unit] +Description=Weekly Dakota Count Me telemetry reporting +ConditionPathExists=/run/ostree-booted +ConditionPathExists=/usr/share/ublue-os/image-info.json + +[Service] +Type=oneshot +DynamicUser=yes +StateDirectory=dakota-countme +ExecStart=/usr/libexec/dakota-countme diff --git a/system_files/shared/usr/lib/systemd/system/dakota-countme.timer b/system_files/shared/usr/lib/systemd/system/dakota-countme.timer new file mode 100644 index 000000000..57b98ae21 --- /dev/null +++ b/system_files/shared/usr/lib/systemd/system/dakota-countme.timer @@ -0,0 +1,14 @@ +[Unit] +Description=Weekly Dakota Count Me telemetry timer +ConditionPathExists=/run/ostree-booted +ConditionPathExists=/usr/share/ublue-os/image-info.json + +[Timer] +OnBootSec=10m +OnCalendar=weekly +Persistent=true +AccuracySec=1h +RandomizedDelaySec=12h + +[Install] +WantedBy=timers.target diff --git a/system_files/shared/usr/libexec/dakota-countme b/system_files/shared/usr/libexec/dakota-countme new file mode 100755 index 000000000..093552da5 --- /dev/null +++ b/system_files/shared/usr/libexec/dakota-countme @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# /usr/libexec/dakota-countme +# Weekly user-counting client for Dakota (bootc) systems +set -euo pipefail + +IMAGE_INFO="/usr/share/ublue-os/image-info.json" +STATE_DIR="${STATE_DIRECTORY:-/var/lib/dakota-countme}" +NOW=$(date +%s) +WEEK=604800 + +# 1. Enforce that we are running on Dakota +if [ ! -f "$IMAGE_INFO" ]; then + exit 0 +fi + +IMAGE_NAME=$(jq -r '."image-name" // empty' "$IMAGE_INFO") +if [ "$IMAGE_NAME" != "dakota" ]; then + exit 0 +fi + +# Ensure state directory exists +if [ ! -d "$STATE_DIR" ]; then + mkdir -p "$STATE_DIR" +fi + +EPOCH_FILE="${STATE_DIR}/epoch" +LASTRUN_FILE="${STATE_DIR}/lastrun" + +# 2. Initialize epoch file on first run +if [ ! -f "$EPOCH_FILE" ]; then + echo "$NOW" > "$EPOCH_FILE" +fi +EPOCH=$(cat "$EPOCH_FILE") + +# 3. Throttling: only run once per 7-day window +if [ -f "$LASTRUN_FILE" ]; then + LASTRUN=$(cat "$LASTRUN_FILE") + if (( NOW - LASTRUN < WEEK )); then + exit 0 + fi +fi + +# 4. Compute Fedora-style week bucket +WEEKS=$(( (NOW - EPOCH) / WEEK + 1 )) +if (( WEEKS == 1 )); then + BUCKET=1 # First week +elif (( WEEKS >= 2 && WEEKS <= 4 )); then + BUCKET=2 # First month (weeks 2-4) +elif (( WEEKS >= 5 && WEEKS <= 24 )); then + BUCKET=3 # First 6 months (weeks 5-24) +else + BUCKET=4 # >6 months +fi + +# 5. Extract image metadata +IMAGE_TAG=$(jq -r '."image-tag" // "unknown"' "$IMAGE_INFO") +IMAGE_FLAVOR=$(jq -r '."image-flavor" // "unknown"' "$IMAGE_INFO") +ARCH=$(uname -m) +OS_VERSION=$(grep '^VERSION_ID=' /etc/os-release | cut -d= -f2 | tr -d '"' || echo "unknown") + +# 6. Formulate request +USER_AGENT="dakota-countme (${IMAGE_NAME} ${OS_VERSION}; ${IMAGE_FLAVOR}; Linux.${ARCH})" +URL="https://countme.projectbluefin.io/metalink?repo=${IMAGE_NAME}&tag=${IMAGE_TAG}&flavor=${IMAGE_FLAVOR}&arch=${ARCH}&countme=${BUCKET}" + +# 7. Ping the server (empty GET) +if curl -sf --retry 3 --max-time 10 -H "User-Agent: ${USER_AGENT}" "$URL" >/dev/null; then + echo "$NOW" > "$LASTRUN_FILE" +fi From e7f3d9f9ec8dedda86b79536b27874ff1a754590 Mon Sep 17 00:00:00 2001 From: Jorge Castro Date: Mon, 27 Jul 2026 21:47:25 -0400 Subject: [PATCH 2/3] fix(telemetry): add network-online dependency and correct countme endpoint docs Add Wants=/After=network-online.target to dakota-countme.service so the oneshot waits for connectivity before POSTing. Update image-registry.md to reflect the actual /metalink endpoint used by the client. Assisted-by: Claude Sonnet 4.6 via GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/skills/image-registry.md | 2 +- .../shared/usr/lib/systemd/system/dakota-countme.service | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/skills/image-registry.md b/docs/skills/image-registry.md index 13a2ba6f2..87f0d5962 100644 --- a/docs/skills/image-registry.md +++ b/docs/skills/image-registry.md @@ -106,7 +106,7 @@ Our images participate in weekly CountMe telemetry to track installation statist - **Dakota:** Since it is based on GNOME OS and has no standard rpm-ostree/dnf packages, it uses a production-grade client-server implementation: - **Systemd units:** `dakota-countme.timer` and `dakota-countme.service`, centralized in `common/system_files/shared/`, trigger `/usr/libexec/dakota-countme`. - **State directory:** Uses a secure systemd `StateDirectory=/var/lib/dakota-countme/` with `DynamicUser=yes`; the `epoch` and `lastrun` files are stored there to track installation age buckets. - - **Server & request format:** Queries our custom Cloudflare Worker at `https://countme.projectbluefin.io` with query parameters `?repo=${IMAGE_NAME}&tag=${IMAGE_TAG}&flavor=${IMAGE_FLAVOR}&arch=${ARCH}&countme=${BUCKET}` and a `dakota-countme` User-Agent. + - **Server & request format:** Queries our custom Cloudflare Worker at `https://countme.projectbluefin.io/metalink` with query parameters `?repo=${IMAGE_NAME}&tag=${IMAGE_TAG}&flavor=${IMAGE_FLAVOR}&arch=${ARCH}&countme=${BUCKET}` and a `dakota-countme` User-Agent. ### Dashboard processing dependency diff --git a/system_files/shared/usr/lib/systemd/system/dakota-countme.service b/system_files/shared/usr/lib/systemd/system/dakota-countme.service index 95645cafc..ecb36c251 100644 --- a/system_files/shared/usr/lib/systemd/system/dakota-countme.service +++ b/system_files/shared/usr/lib/systemd/system/dakota-countme.service @@ -1,5 +1,7 @@ [Unit] Description=Weekly Dakota Count Me telemetry reporting +Wants=network-online.target +After=network-online.target ConditionPathExists=/run/ostree-booted ConditionPathExists=/usr/share/ublue-os/image-info.json From 59db2ee4d781fcc3daddaeea81fa67afc35adaf4 Mon Sep 17 00:00:00 2001 From: Jorge Castro Date: Fri, 31 Jul 2026 18:21:33 -0400 Subject: [PATCH 3/3] fix(telemetry): address countme privacy and opt-out review Assisted-by: Copilot via GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Justfile | 1 + docs/skills/image-registry.md | 5 +- docs/skills/index.json | 2 +- docs/skills/index.md | 2 +- .../lib/systemd/system/dakota-countme.service | 1 + .../lib/systemd/system/dakota-countme.timer | 1 + .../shared/usr/libexec/dakota-countme | 20 +++++++- tests/test_dakota_countme.bats | 49 +++++++++++++++++++ 8 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 tests/test_dakota_countme.bats diff --git a/Justfile b/Justfile index 1df444f37..9593db363 100644 --- a/Justfile +++ b/Justfile @@ -24,6 +24,7 @@ test: bats tests/test_oem_brew.bats bats tests/test_hardware_hooks.bats bats tests/test_nvidia_flatpak_sync.bats + bats tests/test_dakota_countme.bats # Preview Bazaar config from this checkout on the local machine bazaar-preview: diff --git a/docs/skills/image-registry.md b/docs/skills/image-registry.md index 87f0d5962..ad8fb68ed 100644 --- a/docs/skills/image-registry.md +++ b/docs/skills/image-registry.md @@ -101,12 +101,13 @@ The Containerfile pulls wallpaper artwork from `ghcr.io/ublue-os/bluefin-wallpap ## CountMe telemetry reporting -Our images participate in weekly CountMe telemetry to track installation statistics anonymously: +Our images participate in weekly CountMe telemetry using coarse installation-age buckets: - **Bluefin & Bluefin LTS:** Handled by standard repository configuration, and since CentOS-based bootc images are broken with legacy rpm-ostree countme, they use a dnf5-based helper service. - **Dakota:** Since it is based on GNOME OS and has no standard rpm-ostree/dnf packages, it uses a production-grade client-server implementation: - **Systemd units:** `dakota-countme.timer` and `dakota-countme.service`, centralized in `common/system_files/shared/`, trigger `/usr/libexec/dakota-countme`. - **State directory:** Uses a secure systemd `StateDirectory=/var/lib/dakota-countme/` with `DynamicUser=yes`; the `epoch` and `lastrun` files are stored there to track installation age buckets. - - **Server & request format:** Queries our custom Cloudflare Worker at `https://countme.projectbluefin.io/metalink` with query parameters `?repo=${IMAGE_NAME}&tag=${IMAGE_TAG}&flavor=${IMAGE_FLAVOR}&arch=${ARCH}&countme=${BUCKET}` and a `dakota-countme` User-Agent. + - **Server & request format:** The client sends a GET request to our custom Cloudflare Worker at `https://countme.projectbluefin.io/metalink` with query parameters `repo`, `tag`, `flavor`, `arch`, and `countme`. Its `dakota-countme` User-Agent includes the image name, OS version, image flavor, and architecture. The client does not add a machine ID, hostname, username, or persistent token to those fields. Server-side request logging and source-IP retention are not specified in this repository. + - **Opt out:** Create `/etc/dakota-countme/disabled` (for example, `sudo install -d -m 0755 /etc/dakota-countme && sudo touch /etc/dakota-countme/disabled`). Both the timer and service then skip execution; the script also exits before sending a request if invoked manually. ### Dashboard processing dependency diff --git a/docs/skills/index.json b/docs/skills/index.json index e41096127..1a57cfc0b 100644 --- a/docs/skills/index.json +++ b/docs/skills/index.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-07-30", + "generated_at": "2026-07-31", "schema_version": "1.0", "skills": [ { diff --git a/docs/skills/index.md b/docs/skills/index.md index beeb1b8f3..45d122154 100644 --- a/docs/skills/index.md +++ b/docs/skills/index.md @@ -3,7 +3,7 @@ This file is a human-readable mirror of `index.json`. Both are generated by `scripts/generate_skill_index.py` — do not hand-edit either file. -Generated: 2026-07-30 · schema 1.0 · 39 skills +Generated: 2026-07-31 · schema 1.0 · 39 skills | id | category | status | one-line purpose | |---|---|---|---| diff --git a/system_files/shared/usr/lib/systemd/system/dakota-countme.service b/system_files/shared/usr/lib/systemd/system/dakota-countme.service index ecb36c251..2ce17ebe7 100644 --- a/system_files/shared/usr/lib/systemd/system/dakota-countme.service +++ b/system_files/shared/usr/lib/systemd/system/dakota-countme.service @@ -4,6 +4,7 @@ Wants=network-online.target After=network-online.target ConditionPathExists=/run/ostree-booted ConditionPathExists=/usr/share/ublue-os/image-info.json +ConditionPathExists=!/etc/dakota-countme/disabled [Service] Type=oneshot diff --git a/system_files/shared/usr/lib/systemd/system/dakota-countme.timer b/system_files/shared/usr/lib/systemd/system/dakota-countme.timer index 57b98ae21..ae9dd1cea 100644 --- a/system_files/shared/usr/lib/systemd/system/dakota-countme.timer +++ b/system_files/shared/usr/lib/systemd/system/dakota-countme.timer @@ -2,6 +2,7 @@ Description=Weekly Dakota Count Me telemetry timer ConditionPathExists=/run/ostree-booted ConditionPathExists=/usr/share/ublue-os/image-info.json +ConditionPathExists=!/etc/dakota-countme/disabled [Timer] OnBootSec=10m diff --git a/system_files/shared/usr/libexec/dakota-countme b/system_files/shared/usr/libexec/dakota-countme index 093552da5..5c7296ddf 100755 --- a/system_files/shared/usr/libexec/dakota-countme +++ b/system_files/shared/usr/libexec/dakota-countme @@ -3,11 +3,26 @@ # Weekly user-counting client for Dakota (bootc) systems set -euo pipefail -IMAGE_INFO="/usr/share/ublue-os/image-info.json" +DISABLED_FILE="${DISABLED_FILE:-/etc/dakota-countme/disabled}" +IMAGE_INFO="${IMAGE_INFO:-/usr/share/ublue-os/image-info.json}" STATE_DIR="${STATE_DIRECTORY:-/var/lib/dakota-countme}" -NOW=$(date +%s) WEEK=604800 +# Allow users to opt out without disabling the timer. +[ -e "$DISABLED_FILE" ] && exit 0 + +if ! command -v jq >/dev/null; then + echo "dakota-countme: jq is unavailable; skipping telemetry" >&2 + exit 0 +fi + +if ! command -v curl >/dev/null; then + echo "dakota-countme: curl is unavailable; skipping telemetry" >&2 + exit 0 +fi + +NOW=$(date +%s) + # 1. Enforce that we are running on Dakota if [ ! -f "$IMAGE_INFO" ]; then exit 0 @@ -41,6 +56,7 @@ if [ -f "$LASTRUN_FILE" ]; then fi # 4. Compute Fedora-style week bucket +# Source: https://github.com/fedora-infra/mirrors-countme#client-behavior--configuration WEEKS=$(( (NOW - EPOCH) / WEEK + 1 )) if (( WEEKS == 1 )); then BUCKET=1 # First week diff --git a/tests/test_dakota_countme.bats b/tests/test_dakota_countme.bats new file mode 100644 index 000000000..121f2968c --- /dev/null +++ b/tests/test_dakota_countme.bats @@ -0,0 +1,49 @@ +#!/usr/bin/env bats + +SCRIPT_UNDER_TEST="$BATS_TEST_DIRNAME/../system_files/shared/usr/libexec/dakota-countme" +SERVICE_UNIT="$BATS_TEST_DIRNAME/../system_files/shared/usr/lib/systemd/system/dakota-countme.service" +TIMER_UNIT="$BATS_TEST_DIRNAME/../system_files/shared/usr/lib/systemd/system/dakota-countme.timer" + +setup() { + WORKDIR="$(mktemp -d)" + mkdir -p "${WORKDIR}/bin" +} + +teardown() { + rm -rf "${WORKDIR}" +} + +@test "dakota-countme: exits before checking dependencies when disabled" { + touch "${WORKDIR}/disabled" + + run env PATH="${WORKDIR}/bin" DISABLED_FILE="${WORKDIR}/disabled" \ + "${BASH}" "${SCRIPT_UNDER_TEST}" + + [ "${status}" -eq 0 ] + [ -z "${output}" ] +} + +@test "dakota-countme: safely skips when jq is unavailable" { + printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "${WORKDIR}/bin/curl" + chmod +x "${WORKDIR}/bin/curl" + + run env PATH="${WORKDIR}/bin" "${BASH}" "${SCRIPT_UNDER_TEST}" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"jq is unavailable; skipping telemetry"* ]] +} + +@test "dakota-countme: safely skips when curl is unavailable" { + printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "${WORKDIR}/bin/jq" + chmod +x "${WORKDIR}/bin/jq" + + run env PATH="${WORKDIR}/bin" "${BASH}" "${SCRIPT_UNDER_TEST}" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"curl is unavailable; skipping telemetry"* ]] +} + +@test "dakota-countme: units honor the documented opt-out file" { + grep -Fx 'ConditionPathExists=!/etc/dakota-countme/disabled' "${SERVICE_UNIT}" + grep -Fx 'ConditionPathExists=!/etc/dakota-countme/disabled' "${TIMER_UNIT}" +}