Vivian Voss

The Report periodic Sends Anyway

technical beauty scope freebsd base system periodic

Technical Beauty Scope ■ the measurement sheet

Here is the whole of /etc/periodic/daily on a freshly installed FreeBSD 15 system, in the order it runs:

100.clean-disks      400.status-disks     460.status-mail-rejects
110.clean-tmps       401.status-graid     480.leapfile-ntpd
120.clean-preserve   404.status-zfs       480.status-ntpd
130.clean-msgs       406.status-gmirror   500.queuerun
140.clean-rwho       407.status-graid3    510.status-world-kernel
150.clean-hoststat   408.status-gstripe   800.scrub-zfs
200.backup-passwd    409.status-gconcat   801.trim-zfs
210.backup-aliases   410.status-mfi       999.local
221.backup-gpart     420.status-network
222.backup-gmirror   430.status-uptime
223.backup-zfs       440.status-mailq
300.calendar         450.status-security
310.accounting

Thirty-three files. The numbers in front of the names are the entire scheduling mechanism: shell glob order, nothing else. No dependency graph, no declaration of what runs after what, no engine to resolve any of it. Somebody wanted clean-disks to happen before status-disks and gave one a lower number, and that decision has held since the Clinton administration.

Read the names slowly and you have a rather complete answer to the question of what a Unix machine ought to check on itself. Are the passwd files backed up. Did the ZFS pools scrub. Is the mail queue moving. Has anybody added a setuid binary. The last entry, 999.local, is the door left open for whatever you want to add, and it needs no registration anywhere.

I measured all of this on a FreeBSD 15.0 jail rolled back to a clean image this morning. Every number below has its command beside it.

The driver is one hundred and twenty-three lines

/usr/sbin/periodic is 4,468 bytes of POSIX shell. Strip the comments and blank lines and 123 remain. It contains exactly one function, called output_pipe, which decides whether a chunk of text goes to a mail command, to a file, or to standard output.

The rest is a loop. For each argument it takes a lock, then re-runs itself:

lockf -s -t 0 "${lockfile}" /bin/sh $0 LOCKED "$arg"

That is the concurrency model in its entirety. If yesterday's run is somehow still going, lockf fails immediately, the new run says so and stops. Nobody wrote a scheduler for this, because lockf(1) was already in the base system and does the job.

The part worth slowing down for is how a script tells periodic whether its output matters. There is no logging library, no severity enum, no structured event. There is the exit status: zero when nothing notable happened, one for information, two for a warning, and anything above two for something the operator should look at now.

Four numbers, and every one of the fifty-six scripts speaks that vocabulary. The configuration file then says, per script, which of those levels you actually want in your morning mail. A machine that has nothing to report sends nothing, because the scripts all exited zero and the operator asked not to be told about zeroes.

What runs it, and what it runs cron ⊣ three lines in /etc/crontab /usr/sbin/periodic 123 lines of shell ⊣ one function ⊣ lockf(1) daily 33 scripts security 15 scripts weekly 5 scripts monthly 3 scripts 56 scripts, 1,822 lines exit status → 0 ⊣ 1 ⊣ 2 ⊣ above 2 the entire reporting vocabulary configuration filter 132 switches in /etc/defaults/periodic.conf, none of them yours mail to root, 41 lines

Anyone who has tried to make a modern agent quiet during a planned migration will recognise what that saves. Here the filter lives one layer below the noise, in the exit status of the thing that produced it, and the thing that produced it is forty lines of shell you can read.

What it costs to run

The daily set, all thirty-three scripts including the nested security run: 0.26 seconds and 6,020 KB of resident memory, median of three runs. The security set on its own: 0.16 seconds, 6,012 KB. Monthly is 0.04 seconds and does not trouble the machine at all.

The weekly set is the outlier and I would rather name it than bury it: 2.00 seconds and 35 MB, almost all of it spent rebuilding the locate and whatis databases. That is real work with a real result, and it happens once a week at a quarter past four on a Saturday morning.

Seconds per run, median of three weekly 2.00 s 5 scripts, 35 MB, rebuilds locate and whatis daily 0.26 s 33 scripts, 6,020 KB, includes the nested security run security 0.16 s 15 scripts, 6,012 KB monthly 0.04 s 3 scripts, 2,708 KB FreeBSD 15.0-RELEASE, jail rolled back to a clean image, /usr/bin/time -l

The report itself came to 41 lines and 1,309 bytes. Thirty-eight lines and 632 bytes for the security run. A person reads that over the first coffee and is finished before it goes cold.

Underneath: 1,822 lines of shell across all fifty-six scripts, comments and blanks removed. The defaults file is 420 lines carrying 132 switches. Runtime dependencies come to none in the usual sense, since there is no binary to link: cron starts it, sh runs it, mail delivers it, and all three arrived with the system.

Three lines in /etc/crontab are what schedules the whole arrangement.

1  3 * * *  root  periodic daily
15 4 * * 6  root  periodic weekly
30 5 1 * *  root  periodic monthly
Three moments in the night 00:00 06:00 12:00 18:00 24:00 03:01 daily 33 scripts, 0.26 s 04:15 weekly Saturdays, 5 scripts, 2.00 s 05:30 monthly first of the month, 3 scripts, 0.04 s The same three times stand in Apple's manual page for its inherited copy

Security: two entries, both from the last century

The National Vulnerability Database holds two vulnerabilities against periodic. CVE-1999-0761, published in 2000, a buffer overflow in the fts routines that a local user could reach through the program, scored 7.2. CVE-2000-0890, published in 2001, a symlink attack in FreeBSD 4.1.1 and earlier, scored 1.2. Nothing since.

The caveat this series always attaches applies with some force here: attention manufactures CVE numbers, and a shell script in a base system attracts rather little of it. Two entries in twenty-eight years is a pleasant sheet, and it is not a certificate.

The four questions get straighter answers. It runs as root, which is the honest price of a program that inspects passwd files and mounts. It takes no data from the network. It gives up no privileges, having been given none to trade. The fourth question, about parsing complex formats, is where an eyebrow belongs. periodic.conf never goes through a parser at all: the driver sources it, so the configuration file is executable shell code running with full privileges. There is a long tradition behind that choice, and the practical consequence is that write access to /etc/periodic.conf amounts to root.

Worth adding that a good part of what the tool checks is itself security work. 100.chksetuid reports setuid binaries that appeared overnight. 300.chkuid0 looks for accounts that have quietly acquired uid zero. 400.passwdless finds empty passwords, 800.loginfail counts failed logins, and 520.pfdenied summarises what the firewall turned away. Fifteen scripts, and the whole set runs in a sixth of a second.

Longevity: three families, and one that let it go

periodic first appeared in FreeBSD 3.0, released on 16 October 1998. Twenty-seven years and eleven months in the base system, written by Paul Traina and Brian Somers, and the manual page has carried both names ever since.

The BSD family split three ways on the idea. OpenBSD and NetBSD kept the older shape: three shell scripts at /etc/daily, /etc/weekly and /etc/monthly, with a separate security(8) for the nightly insecurity report, and local additions going into .local files beside them. FreeBSD took the same idea in 1998 and moved it into a directory of small scripts with a configuration layer on top, which is why there are 132 switches and why a package can drop a file into /usr/local/etc/periodic/ and be picked up without touching anything. Linux has /etc/cron.daily and its siblings driven by run-parts, which schedules the same way and has no equivalent of the configuration layer or of the report as a concept, so the reporting is generally bought in.

And then there is the system I am typing this on. macOS ran Apple's inherited copy of periodic for two decades. On macOS 26.5.2 the binary is gone, /etc/periodic/ is gone, there is no /etc/crontab, and man periodic finds nothing. Apple removed it in macOS 15, after years of deprecation, on the grounds that logd and its siblings had taken over the actual work.

That reasoning is sound, and it deserves saying plainly instead of being propped up as a straw man. A system with a managed log service genuinely does not need a shell script to roll logs. What went with it was the part that had nothing to do with logs: the habit of a machine composing a short written account of itself every night and sending it to a person. On FreeBSD that habit is a file you can read, and on the machine in front of me it is an absence.

Stability: nothing to migrate

The three crontab lines above are the same three lines documented in Apple's manual page for its own inherited version, at the same minutes past the same hours. The variable names in periodic.conf follow one pattern and have followed it for decades, which is why a script written for 999.local in 1999 needs no adjustment now.

Then there is the file I kept looking for and could not find. A fresh FreeBSD install has no /etc/periodic.conf. No empty stub, no commented template: the file simply does not exist, and the system reports for duty every morning regardless. All 132 switches live in /etc/defaults/periodic.conf, which you never edit, and your own file is created only when you first want to disagree with something. Zero lines of configuration, and the machine still writes.

A tool that reports faithfully to an empty room has done its job and achieved nothing.

The limit

The resolution is one day. Something that breaks at ten past three in the morning is in tomorrow's mail, and if you need to know within thirty seconds, this is not the tool and was never meant to be.

The output is prose for humans. There is no query language, no retention, no way to ask what the mail queue looked like on a Tuesday in June. Anybody who needs that history has to keep it themselves.

The delivery has a well-known failure mode, and no amount of engineering addresses it. Mail to root goes to a mailbox most people never open, so the report accumulates somewhere in /var/mail and nobody reads a line of it. Two lines fix that: a real address against root in /etc/aliases, then newaliases, and the machine starts talking to a person.

There is no timeout on a script. I found this the hard way whilst measuring: the run hung for over three minutes on 405.pkg-base-audit, a package-supplied script fetching a vulnerability database, and periodic waited patiently the entire time because waiting is what it does. None of the base system's own scripts reaches outside the machine, so this particular trouble arrives with the packages you add.

And the root question stands. It runs as root, it sources its configuration as shell, and both facts are older than most of the people running it.

The sheet

Somewhere on a machine you have not logged into for a year, at one minute past three, fifty-six small scripts are working through a checklist somebody wrote before the euro existed, and the mail has been going out the whole time.