Skip to content

fix(roles/repo_*): deploy repository files carrying mirror credentials with mode 0600 - #321

Open
markuslf wants to merge 1 commit into
mainfrom
fix/repo-file-mode
Open

fix(roles/repo_*): deploy repository files carrying mirror credentials with mode 0600#321
markuslf wants to merge 1 commit into
mainfrom
fix/repo-file-mode

Conversation

@markuslf

@markuslf markuslf commented Aug 21, 2026

Copy link
Copy Markdown
Member

25 template tasks across 21 repo_* roles render a mirror password into a repository file below /etc/yum.repos.d or /etc/zypp/repos.d and left it world-readable at 0644, so every local account on the host could read the credentials of our package mirror.

The mode is now conditional on the role's basic_auth_login instead of a flat 0600:

      # 0600 while the file carries the mirror credentials
      mode: '{{ "0o600" if repo_grafana__basic_auth_login | default({}) | length > 0 else "0o644" }}'

Why conditional and not simply 0600

0600 has a price that is easy to miss. An unprivileged dnf skips a repository file it cannot read without a single word on stderr, so dnf list and dnf repolist stop showing those repositories and give no hint why. Measured on Rocky Linux 9.7 with dnf-4.14.0-31.el9 and libdnf-0.69.0-16.el9, /etc/yum.repos.d/rocky.repo set to 0600:

dnf repolist as root    -> appstream, baseos, extras
dnf repolist as tester  -> extras only, stderr empty, exit code 0
dnf list bash as tester -> installed version only, no available version

That is worth paying where there is a secret to protect, and not worth paying on the majority of hosts, which use the vendor repositories without authentication. A repository file without credentials therefore keeps 0644.

Why the skip is silent

Reading dnf alone suggests the opposite, because dnf/conf/read.py does have a warning path:

try:
    parser.read(repofn)
except RuntimeError as e:
    raise dnf.exceptions.ConfigError(...)   # -> "Warning: failed loading '%s', skipping."
except IOError as e:
    logger.warning(e)

The exception never gets that far. libdnf throws IniParser::CantOpenFile on EACCES, ConfigParser::read re-throws it as ConfigParser::CantOpenFile, and the SWIG layer maps it to a Python IOError. But bindings/swig/conf.i then replaces ConfigParser.read with a Python shim that swallows exactly that:

try:
    self.readFileName(fname)
    parsedFNames.append(fname)
except IOError:
    pass

The parser is left with zero sections, dnf iterates zero repositories, and nothing is logged. Calling the binding directly confirms it, as an unprivileged user:

/etc/yum.repos.d/rocky.repo (0600):     ConfigParser.read OK, sections=[]
/etc/yum.repos.d/does-not-exist.repo:   ConfigParser.read OK, sections=[]

Rocky Linux 10.1 still ships dnf 4 with libdnf, so this holds across RHEL 8, 9 and 10.

dnf5 behaves differently

On Fedora 43 with dnf5-5.2.18 an unreadable repository file is fatal rather than silent:

runuser -u tester -- dnf repolist
exit code 1, stdout empty
Unable to access configuration file "/etc/yum.repos.d/fedora.repo"
 cannot open file: (13) - Permission denied

A single 0600 repository file breaks every unprivileged dnf call on the host, not just the one repository. No repo_* role lists Fedora in COMPATIBILITY.md, so nothing here is affected today, but the conditional mode matters more once dnf5 reaches RHEL, not less.

Why the expression looks the way it does

The obvious spelling is silently wrong, so both forms were measured with ansible-playbook rather than assumed:

mode: "{{ 0o600 if ... }}"      -> 644   (the integer is rendered and never applied)
mode: '{{ "0o600" if ... }}'    -> 600   credential set
                                -> 644   variable undefined
                                -> 644   variable defined but empty

Notes for the reviewer

  • GPG key tasks keep 0644, only the repository files change.
  • The Debian side needs no change: the apt sources templates carry no inline credentials.
  • Quoting, | length > 0 and the 0o octal notation follow CONTRIBUTING; the one existing precedent for a mode inside Jinja is roles/nfs_client.

…s with mode 0600

25 template tasks across 21 repo roles render a mirror password into a repository
file below /etc/yum.repos.d or /etc/zypp/repos.d and left it world-readable at
0644, so every local account could read the credentials of our package mirror.

The mode is conditional on the role's basic_auth_login rather than a flat 0600,
because 0600 has a price: an unprivileged dnf silently skips a repository file it
cannot read, so `dnf list` and `dnf repolist` stop showing those repositories
without saying why. That is worth paying where there is a secret to protect and
not worth paying on the hosts, the majority, that use the vendor repositories
without authentication. A file without credentials therefore keeps 0644.

The expression was measured rather than assumed, since the obvious spelling is
silently wrong: `mode: "{{ 0o600 if ... }}"` renders the integer and ends up as
0644, while the quoted form yields 600 and 644 as intended, for a credential that
is set, for an undefined variable and for an empty one.
@markuslf
markuslf requested a review from NavidSassan August 21, 2026 09:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant