Linux Fleet MOTD Editor. Push a consistent, informative login banner to every host in your fleet over SSH. Back up what is there. Put it all back if you change your mind.
lfme.kosir.info · MIT · agentless
Part of a set: linux-audit collects, linux-harden remediates, linux-motd presents.
Reads a list of hosts, connects over SSH, escalates with sudo, backs up anything it is
about to touch, and installs a small POSIX shell renderer that draws the banner at each
login. Hostname, IP, uptime, load, memory, disk, pending updates, reboot-required, failed
units, and whatever policy text you put in the template.
==============================================================================
PRODUCTION | web01.example.com
==============================================================================
IP : 10.20.4.11 Platform : kvm
OS : Red Hat Enterprise Linux 9.4 (Plow) (5.14.0-427.el9.x86_64)
Owner : Platform
Support : ops@example.com
Uptime : 41d 6h 12m
Load : 0.42 / 0.51 / 0.48 (8 cores, 0.05 per core)
Memory : 11.2G / 15.5G (72%)
Disk / : 18.1G / 40.0G (46%)
Disk /var : 7.9G / 8.0G (98 !!%)
Sessions : 2
Updates : 14 pending, 3 !! security
!! REBOOT REQUIRED
!! 2 failed unit(s): chronyd.service,rsyslog.service
INTERNAL. Authorised use only. Activity is logged.
Raise changes at https://jira.example.com/servicedesk
==============================================================================
pip install linux-motdOr from source:
git clone https://github.com/vikozs/linux-motd.git
cd linux-motd
python3 -m pip install -e ".[dev]"# See what one host would look like. Writes nothing.
linux-motd preview -i inventory.yaml -H web01.example.com
# Show the change plan and diffs for the fleet. Writes nothing.
linux-motd plan -i inventory.yaml
# Install, with a per-host approval gate.
linux-motd push -i inventory.yaml
# Password auth with a domain service account.
linux-motd push -i inventory.yaml --ask-ssh-pass --sudo-pass-same-as-ssh
# Unattended, for CI. Still takes backups.
linux-motd push -i inventory.yaml --yes --json run.json
# Put it back.
linux-motd rollback -H web01.example.com
linux-motd remove -i inventory.yaml| Command | What it does |
|---|---|
preview |
Render against one host's live facts. No writes. |
plan |
Pre-flight every host, print the change plan and diffs. No writes. |
push |
Back up, install, verify. Approval-gated. |
status |
Installed version, timer state, cache age per host. |
verify |
Check installed state against what this config would produce. |
rollback |
Restore a backup generation. |
remove |
Uninstall and restore pre-install state. |
fields |
List every template field. |
0 all succeeded · 1 a host failed · 2 config/inventory/template error, nothing
contacted · 3 nothing to do · 4 aborted by operator · 5 partial apply with rollback,
needs a human.
There is no single MOTD mechanism across a mixed fleet, so the tool detects and adapts.
| Family | Mechanism | Notes |
|---|---|---|
| Debian, Ubuntu | /etc/update-motd.d/ |
pam executes these. Native dynamic support. |
| RHEL/Rocky/Alma 8, 9 | /etc/motd.d/ |
pam reads these as static text. Never executes them. The refresh timer writes the rendered output here. |
| RHEL/CentOS 7 | /etc/profile.d/ |
That pam version reads /etc/motd only. |
Three hook modes, auto-detected, overridable per group:
pam— output written where pam looks. Works for every login type, including non-bash shells. Live fields are as fresh as the timer.profile— a/etc/profile.dscript calls the renderer at shell start. Every field is live. Bash/sh login shells only.both— static banner plus a live block. Default on RHEL 8/9.
Anything that runs at login runs on every SSH session, including your monitoring checks and backup jobs. So the split is enforced from the first commit:
- Fast path (
linux-motd-render, budget 50 ms):/proc,/etc/os-release, a cache file. No package manager, no network. - Slow path (
linux-motd-refresh, systemd timer, 30 min with a 5 min random delay): pending updates, reboot-required, failed units. Writesfacts.env.
If the cache is missing or corrupt, the fast path omits those fields silently. It never blocks a login.
- Backups first. Every write is preceded by a backup into
/var/backups/linux-motd/<timestamp>/with amanifest.jsonrecording content, mode, owner and SELinux context. Backups are never deleted on the failure path. - Rollback deletes. The manifest records
existed: falsefor paths that were not there before. Rollback removes them rather than leaving them behind. - Read-back verification. Every write is re-read and hashed. A write that reports
success but did not land is a failure, not a pass. On SELinux hosts the context is checked
too: a file that lands with
user_tmp_tis ignored by pam while the tool would otherwise claim success. - Per-host atomicity. If any file in the set fails, the whole host rolls back.
- Foreign management detection. cloud-init, Puppet, Ansible and Salt markers are detected and reported rather than silently overwritten.
- Approval gate. Unified diff per host, then
y / n / a / q / d.--dry-runwrites nothing;--yesskips prompts for automation and still takes backups.
A /etc/profile.d script that writes to stdout unconditionally corrupts scp, sftp and
rsync, fleet-wide, and the symptom does not obviously point back at a login banner. The
generated hook guards on interactivity, on stdout being a tty, and on an opt-out variable,
and the test suite runs a real scp against an installed host. Do not remove those guards.
/etc/issue and /etc/issue.net are supported, off by default, behind --enable-issue.
They are displayed before authentication, so they get their own template with a restricted
namespace: only meta.* policy fields load, and every host, system and package field is
rejected at load time rather than rendering blank. Backslashes are escaped by default,
because getty expands \m and \r in /etc/issue while sshd does not expand them in
/etc/issue.net, and the same bytes would otherwise leak kernel and hostname in one place
and render literally in the other.
Ownership is split with linux-harden and the split is written down:
| Concern | Owner |
|---|---|
/etc/issue content |
linux-motd, inside sentinel markers |
| File mode and ownership (CIS 1.7.4-1.7.6) | linux-harden |
sshd_config Banner directive |
linux-harden |
Only text between the sentinels is replaced; everything outside is preserved byte for byte. The feature stays off by default until linux-harden v1.2 stops writing issue content, so the two tools never fight over one file.
Two files, both documented in examples/:
inventory.yaml— shared with linux-audit and linux-harden via linux-fleet-commonconfig.yaml— operator-side settings. Never contains passwords.
Templates use {{ field }}, {{# if field }}, {{# each disk.mounts as m }}, and the
filters default, pad, warn, upper, lower. Run linux-motd fields for the full
field list. An unknown field is a load-time error, not a blank at render time.
Passwords travel via stdin or environment, never argv, because /proc is world-readable.
sudo -S and bash -s both want stdin, so scripts are staged over SFTP to a mode-0600 temp
file and executed by path, with only the password on stdin. Staged files are removed in a
finally block.
LINUX_MOTD_SSH_PASSWORD and LINUX_MOTD_SUDO_PASSWORD are read from the environment for
unattended runs.
pip install -e ".[dev]"
pytest
ruff check .The test suite executes the generated shell under a real sh rather than inspecting it as a
string. Reviewing generated shell statically does not count as testing it.
MIT. See LICENSE.
Built by vK, who has been paged at 03:00 and did not enjoy it.