39 Commits
Author SHA1 Message Date
jcoffey-dev ef954ce1e2 Merge branch 'ci/gitea-actions' into 'main'
ci / shellcheck (push) Successful in 1m41s
ci: add Gitea Actions workflow

See merge request coffey-labs/SysAdminAutomation!2
2026-09-21 23:34:17 -07:00
jcoffey-dev edc5630e7d ci: add Gitea Actions workflow ported from .gitlab-ci.yml
ci / shellcheck (pull_request) Successful in 43s
2026-09-21 22:49:40 -07:00
jcoffey-dev 71dfdf7612 Merge branch 'ci/gitlab-pipeline' into 'main'
Run CI on the self-hosted GitLab

See merge request coffey-labs/SysAdminAutomation!1
2026-09-20 20:56:33 -07:00
jcoffey-dev 45be9ca79f Use the shellcheck image that has a shell in it
koalaman/shellcheck:stable is built FROM scratch with shellcheck as the
entrypoint and no /bin/sh, so the runner cannot start a job script in it
and the job fails with an OCI runtime error before shellcheck ever runs.
The -alpine variant is the same tool with a shell around it.
2026-09-20 20:21:17 -07:00
jcoffey-dev 0f17cd6622 Run CI on the self-hosted GitLab
Ports .github/workflows/ci.yml to .gitlab-ci.yml after the GitHub account
was suspended and Actions stopped being reachable. Same checks, same order.

The Actions workflow stays in the tree: it is the reference this was
written from and it works unchanged if the appeal succeeds. The image is
pinned by digest rather than tag, which is the replacement for the
workflow's SHA-pinned actions -- GitLab has no action allowlist to lean on.
2026-09-20 20:11:33 -07:00
jcoffey 4acd4dcf16 Merge pull request #2 from Coffey-Labs/funding-username-jcoffey-dev
Point the Sponsor button at the current GitHub username
2026-09-10 09:19:39 -07:00
jcoffey-dev 3dfa60dba5 Point the Sponsor button at the current GitHub username
The account behind it was renamed from LINUXexpert-org to jcoffey-dev,
and GitHub does not redirect the old name: github.com/sponsors/
LINUXexpert-org answers 404 while the new one answers 200. So the
Sponsor button on this repository has been leading nowhere.

Worth fixing rather than leaving to redirect, because a released
username can be registered by anyone -- a stale link stops being a dead
end and starts being someone else's page.
2026-09-10 09:17:02 -07:00
Coffey Labs 3fa5f15a11 Update GitHub Sponsors username in FUNDING.yml 2026-09-05 00:50:23 -07:00
LINUXexpert.org 4844448ec5 Merge pull request #1 from LINUXexpert-org/copyright-coffey-labs
Change the copyright holder to Coffey Labs
2026-08-30 01:28:37 -07:00
jcoffey-dev 5d417f3c4c Change the copyright holder to Coffey Labs
The scripts were attributed to LINUXexpert.org, which is being retired as a
site and is no longer where this work lives. Coffey Labs is the organisation
these projects belong to.

One line per script, fifteen of them, and nothing else. LICENSE is deliberately
untouched: its "Copyright (C) <year> <name of author>" lines are GPL boilerplate
showing you how to write your own notice, and the Free Software Foundation's
own copyright on the licence text is not ours to edit.

Both git contributors are the same person, so there is no third-party copyright
here that could not be restated.
2026-08-30 01:26:45 -07:00
jcoffey-dev 466aa69533 docs: bring the README in line with what the scripts now do
The old README was a flat list of one-line descriptions, several of
which no longer described the script: log_rotate gained flags and no
longer purges by default, restore and process_monitor now confirm before
acting, rsync_magic changed a default, and disk_cleanup grew guards.

Rewritten around the conventions that are now consistent across the
collection -- --dry-run, a confirmation before anything destructive, an
unattended escape hatch, and a refusal rather than a guess when there is
no tty to ask on. That last one is the part worth knowing before putting
any of these in cron.

Groups the scripts by what they are for rather than listing them
alphabetically, and states the things a reader would otherwise have to
discover by reading source: that security_audit as an ordinary user
proves very little, that log_rotate is not a logrotate replacement and
why, that update_system is unattended on every branch, and that
restore's tar options matter because an archive picks its own ownership
and modes.

Also updated three script headers that had gained ASSUME_YES/FORCE
escapes without documenting them, so the headers and the README agree.

Every flag, environment variable and behaviour claimed here was checked
against the scripts rather than written from memory.
2026-08-22 22:27:40 -07:00
jcoffey-dev 96774aba35 Finish the pass: log_inspect, service_manager, rsync_magic, disk_cleanup
log_inspect.sh discarded grep's stderr and ignored its exit status, so
an unprivileged search over root-owned logs was indistinguishable from a
search that genuinely found nothing. grep's three outcomes now mean
three different things: matched, matched nothing, or could not read
everything -- the last of which says so and exits non-zero. Confirmed
grep returns 2 rather than 1 in that case, which is why the naive
"status -eq 1" check would never have fired.

service_manager.sh validates the action before dispatch and requires
root for the five that change system state, leaving status and list open
to anyone. $action is quoted at both call sites.

rsync_magic.sh had --inplace on unconditionally. It writes straight into
destination files instead of to a temporary and renaming, so an
interrupted run leaves them partially overwritten -- the opposite of
what a backup tool should guarantee. Now opt-in, with a warning when
used. Its log lives under /var/log and every line pipes through tee, so
under pipefail an unprivileged run died on the first line with a bare
permission error; it now falls back to stdout rather than failing the
sync over its own logging. --delete also confirms before running, since
reversing the two arguments erases the backup.

disk_cleanup.sh moves from `set -o pipefail` to full strict mode, with
the two pipelines that legitimately return non-zero handled at their
call sites rather than by leaving the script lax. Its "largest files"
walk also gained -xdev, which it was missing while security_audit.sh
next door already had it -- without it the walk descends /proc, /sys and
every network mount.

All fifteen scripts now run under set -euo pipefail.
2026-08-22 22:24:50 -07:00
jcoffey-dev f8a86736f9 Harden user_manage, update_system, and the Zimbra pair
user_manage.sh ran useradd/userdel/usermod with no privilege check at
all, so an ordinary user got "Failed to create user." with no hint that
root was the missing piece. Mutating subcommands now require root while
listusers/listgroups stay open, and the check runs *after* the
subcommand is recognised so a bare invocation still prints usage instead
of complaining about privileges. Account names are validated before
reaching useradd, and `deluser` -- which removes the home directory
irrecoverably -- prints what it will delete and confirms first.

zimbra_backup.sh reported success on failed backups. getRestURL can
write an HTTP error body and still exit zero, so a " Backup completed"
could sit over a file containing an error page. Size and gzip -t checks
now gate that, and a suspect file is renamed .suspect rather than
deleted, so it can be looked at. zimbra_restore.sh likewise validates
the archive before starting a restore from it.

Both Zimbra scripts had `cmd` followed by `if [ $? -eq 0 ]`. Adding
set -e to those would have made the error branches unreachable -- set -e
exits before the check -- so the tests are inline instead. That would
have been a silent regression rather than a visible one.

update_system.sh gained strict mode, and a note on the pacman branch:
Arch has no supported partial-upgrade path, and --noconfirm answers away
the prompts that would otherwise warn.

Integrity checks verified against an error page, a truncated archive, a
non-gzip file and a real one.
2026-08-22 22:22:05 -07:00
jcoffey-dev 1e676bb634 Make backup and restore safe to actually rely on
backup.sh named archives with %Y%m%d, so a second run on the same day
silently overwrote the first -- destroying a good backup at the moment
someone was trying to take another. Names now carry seconds, and an
existing path is refused rather than clobbered. The archive is also
written to a .partial name and renamed only on success, with a trap to
clean up, so an interrupted run cannot leave a truncated file that looks
like a backup.

restore.sh extracted with tar's defaults. An archive is untrusted input
-- whoever produced it chooses the paths, ownership and modes inside it
-- and extracting as root let the tarball dictate uid/gid and restore
setuid bits directly. Now --no-same-owner --no-same-permissions, with -P
still absent so tar keeps stripping leading "/" and refusing ".."
members. It also lists what it is about to extract and confirms first,
since it silently overwrote whatever was already in the target.

Both scripts also gained set -euo pipefail, and both replaced the
`cmd; if [ $? -eq 0 ]` pattern with a direct `if cmd; then`, which is
what that idiom was reaching for and gets wrong as soon as any command
is inserted between the two lines.

Verified end to end: two same-second-apart backups both survive, no
.partial residue, restore refuses non-interactively, and the round trip
diffs identical.
2026-08-22 22:20:04 -07:00
jcoffey-dev e8c0a73dba Harden the four monitoring scripts
set -euo pipefail across all four, but added deliberately rather than
pasted in -- each script needed the places where a non-zero exit is
normal handled first, or strict mode would have made them worse:

- sys_monitor / process_monitor: `ps | head -n 6` is a latent SIGPIPE.
  head closes the pipe after six lines, and on a host with enough
  processes ps fills the buffer and exits 141, which pipefail turns into
  a script abort -- on exactly the busy machine you wanted to inspect.
  Confirmed the mechanism (a large producer into head returns 141) and
  those pipelines now tolerate it.
- security_audit: find exits non-zero when it cannot read a directory,
  which is routine when walking the whole filesystem. Without handling,
  set -e aborted the audit part way while still looking complete. Also
  notes that a clean report as non-root means little, since find cannot
  descend where it may not read.
- network_info: iptables needs root, so the last section aborted the
  script for ordinary users. Now reports the failure, and falls back to
  nft where iptables is absent.

process_monitor also no longer kills on sight. `pkill -x` by name can
match several processes at once, and as root that is an easy way to take
down more than intended. It now prints what it matched and asks, with
FORCE=1 for unattended use and a refusal rather than a hang when there
is no tty.

All four run clean; the kill path was tested against a live process and
left it alive.
2026-08-22 22:19:05 -07:00
jcoffey-dev 82528a1402 Fix the two ShellCheck warnings and hold CI at that level
The workflow went in at severity: error on the assumption that a
never-linted repository would have a backlog worth grandfathering. It
did not -- error found nothing, and warning found exactly two things, so
the cautious setting was protecting against a problem that was not
there.

zimbra_backup.sh: SC2024, sudo does not affect redirects. The
`> "$BACKUP_FILE"` runs as root rather than as the sudo'd zimbra user,
so backups landed root-owned inside a directory the script deliberately
chowns to zimbra:zimbra. Kept the redirect -- root can always write
there, and piping into `tee` would put tee's status in $? and hide a
zmmailbox failure -- and handed ownership over explicitly afterwards.
The suppression is narrow and states why.

disk_cleanup.sh: SC2034, total_freed was assigned and never read.

CI now holds at warning with a clean tree, so anything that trips it is
new rather than inherited.
2026-08-22 22:15:21 -07:00
jcoffey-dev dc55baa63e ci: probe shellcheck at warning severity 2026-08-22 22:13:54 -07:00
jcoffey-dev 95ec7f4a45 ci: add a ShellCheck workflow
Every file here is a shell script that the README invites people to run
as root, and nothing has been linting them: .github/workflows was
deleted in 5eadf64 and never replaced. The bugs found in this week's
audit -- values interpolated into a command string, an unguarded
find -delete, exit statuses read from $? after the fact -- are largely
the class a linter catches for free.

Starts at severity: error deliberately. The existing scripts have never
been through a lint pass, so failing on warnings from day one would
block every PR on pre-existing findings rather than on new ones. The
intent is to tighten to warning, then to nothing, as the backlog is
cleared.
2026-08-22 22:12:58 -07:00
jcoffey-dev 9be14e9670 Stop log_rotate destroying logs that are still in use
Two problems, both data loss.

It gzipped any *.log older than the threshold. gzip writes the .gz and
unlinks the original, so a daemon holding that file open keeps writing
to an unlinked inode and those writes become unreachable -- the exact
failure real logrotate avoids with copytruncate or a postrotate signal.
We can do neither from here, so files currently held open are now
skipped and left for logrotate, and the run says so. If lsof is missing
the check cannot run, and that is reported rather than assumed safe.

It also deleted every .gz older than a hardcoded 90 days, on every run,
with no flag, no dry-run and no confirmation -- destroying archives on
any host with longer retention. Deletion is now opt-in via --purge-days,
prints what it will remove, and needs an interactive confirmation or
--yes. Without a tty and without --yes it refuses instead of proceeding.

Adds --dry-run, --days, -h, and set -euo pipefail. A bare numeric first
argument still works, so existing `log_rotate.sh 14` callers and cron
entries are unaffected.

Verified against a fixture directory: old logs compressed and recent
ones left alone, archives surviving when --purge-days is absent, purge
refusing non-interactively without --yes, and a file held open by a
running process skipped rather than compressed.
2026-08-22 22:12:31 -07:00
jcoffey-dev db19bef328 Guard disk_cleanup's --dirs, and stop --help hiding it
--dirs accepted any path and fed it to `find -delete` running as root,
with no confirmation: `--clean --dirs /home` removed every file in /home
past the age threshold, and `--dirs /` did it system-wide.

Now refuses protected directories, comparing the readlink -f resolved
path so a symlink or /tmp/../home cannot smuggle one through, and
requires absolute paths. Anything outside the /tmp,/var/tmp defaults
also needs an interactive confirmation -- or --yes, so unattended use
stays possible; without a tty and without --yes it refuses rather than
hanging in cron.

usage() sliced fixed line numbers (head -22 | tail -n +18) and had
already outgrown them, truncating --help mid-list at --age. So --dirs,
the one option that could destroy a system, was the one option --help
never mentioned. Replaced with a sed range that tracks the comment block
wherever it moves.

The first version of the protected-path check let "/" through: it
compared against "${p%/}", which turns the "/" entry into an empty
string that matches nothing. Caught by testing the guard against the
paths it exists to stop, rather than assuming it worked.
2026-08-22 22:11:13 -07:00
jcoffey-dev 1170130dc9 Fix shell injection in the Zimbra backup and restore scripts
Both scripts built a command string by interpolating user input into
bash -c:

    sudo -u zimbra bash -c "... -m '$EMAIL' ..."

The single quotes inside the double-quoted string are not protection --
the outer shell expands $EMAIL first. An address of

    x' ; id ; echo '

closes the quote and runs arbitrary commands. Both scripts require root
and invoke this through sudo -u zimbra, so injected commands execute as
the account that owns the entire mail store. Verified against the exact
quoting pattern before and after the change.

Fixed by single-quoting the script body so nothing is interpolated, and
passing values as positional arguments. The bash -c wrapper is kept
deliberately rather than calling zmmailbox directly, since it may depend
on shell setup and this could not be tested against a live Zimbra.

Two related holes in the same input paths:

- $EMAIL is also part of the backup filename, so a "/" wrote outside
  $BACKUP_DIR. Now validated as a plain address.
- The restore prompt took a filename and concatenated it into a path, so
  "../../etc/shadow" escaped $BACKUP_DIR. Now rejects anything
  containing a separator.

Also switched the backup listing from `ls | grep "$EMAIL"` to a find
with grep -F: unquoted the address was treated as a regex, so "." in it
matched any character.
2026-08-22 22:09:28 -07:00
LINUXexpert.org 4b42bc7a1a Updated disk_cleanup.sh with new content 2026-03-20 09:29:28 -07:00
LINUXexpert.org 9bef611a33 Delete .github/workflows directory 2026-01-22 09:26:31 -07:00
LINUXexpert.org 75fa3d7229 Clean up README by removing badge section
Removed unused badge section from README.
2026-01-22 09:26:00 -07:00
LINUXexpert.org 669a2bc5ee Rename workflow and update job names 2026-01-22 09:25:28 -07:00
LINUXexpert.org 2cc51ab91f Add badges section placeholders to README
Added placeholders for badges section in README.
2026-01-22 09:21:27 -07:00
LINUXexpert.org 85393b824c Create workflow to update README with badges
This workflow updates the README with badges using a scheduled cron job.
2026-01-22 09:20:39 -07:00
LINUXexpert.org 2a62fb2fe5 Update README.md 2025-06-12 16:09:23 -07:00
LINUXexpert.org 12c2785a35 Update zimbra_restore.sh 2025-06-12 16:04:07 -07:00
LINUXexpert.org 8e8ca4a4a3 Update zimbra_backup.sh 2025-06-12 16:03:53 -07:00
LINUXexpert.org 6405d3c324 Update rsync_magic.sh 2025-06-12 16:03:33 -07:00
LINUXexpert.org 6e2c1fc833 Create rsync_magic.sh 2025-06-12 16:00:51 -07:00
LINUXexpert.org 3305b8e8a3 Update README.md 2025-06-11 15:10:54 -07:00
LINUXexpert.org 34b7039619 Create zimbra_restore.sh 2025-06-11 15:09:44 -07:00
LINUXexpert.org 11d2b1cf24 Update README.md 2025-06-11 15:08:06 -07:00
LINUXexpert.org 4cd99be161 Create zimbra_backup.sh 2025-06-11 15:06:24 -07:00
LINUXexpert.org 1a46528e6d Update README.md 2025-05-17 10:07:27 -07:00
LINUXexpert.org b6cb2d31f7 Add files via upload 2025-05-17 10:06:49 -07:00
LINUXexpert.org e7f89c23ca Initial commit 2025-05-17 10:06:21 -07:00