Manager-side cron scheduler

Manager-side cron for FreePBX fleets — customsso-manager v1.0.3+ adds fleet-wide scheduled command dispatch. Create bulk cron jobs from the manager UI that automatically run on every paired FreePBX server and auto-extend to newly-added ones, or per-PBX individual jobs for one-off automations. Schedule with standard 5-field Linux cron (0 2 * * 0), execute any shell command up to 512 characters over SSH, and pause any bulk job on a specific PBX without touching the rest. Every run captures exit code, stdout, stderr, and duration — searchable audit log built in. No per-PBX crontab entries; unpair a PBX and its scheduled work stops cleanly. Zero-runtime-dep, self-hosted, MIT.

Available in v1.0.3+. Requires no PBX-side changes; runs entirely from the manager over the existing pair-time SSH channel.

Two job kinds

Manager-side cron distinguishes two job origins:

Bulk cron job Individual cron job
Applies to Every currently-paired PBX (fleet-wide) One PBX only
Created from Bulk actions → Cron tab Per-PBX Cron tab
Edited from Bulk view only Per-PBX view only
Auto-extends to new pairs Yes (enabled=1 on new pair) No
Per-PBX enable / disable Yes — via per-PBX Cron tab OR bulk edit form's picker Yes — same PBX only
Delete Bulk view (removes every PBX's row atomically) Per-PBX view

The distinction is captured in a single origin column on scheduled_jobs. Bulk-origin rows share a group_id across their fleet-wide siblings; individual-origin rows carry a unique group_id (group of one).

Pick bulk when the same job should run everywhere (fwconsole reload, log rotations, module-list refresh, cert renewal). Pick individual when the schedule is unique to one PBX (customer-specific backup, timezone-shifted maintenance, one-off cleanup).

Bulk cron jobs

Creating

Bulk actions → Cron tab → + Add Bulk Cron Job.

The form shows a PBX picker on the left (checkbox list with search) and the job fields on the right. Every checkbox is checked by default; the checkboxes control the per-PBX enabled state at creation time, not membership. A bulk job always creates a row for every currently-paired PBX — unchecking a PBX just means the row is created with enabled=0 (present but paused).

Fields:

On save, one row per paired PBX lands in scheduled_jobs. The bulk list shows the job with an N/M enabled badge (N = number of PBXes with enabled=1, M = total PBXes in the group).

Per-PBX enable-at-creation

Uncheck any PBX in the picker to have the row created disabled on that PBX. The row still exists (so the group is intact), the job just doesn't fire there until someone re-enables it. Two ways to re-enable later:

  1. Bulk view → Edit the group → check the box in the picker.
  2. Per-PBX Cron tab → find the bulk row in the "Bulk cron jobs" section → click Enable.

Both do the same thing (flip the row's enabled flag).

Editing

Bulk view → Edit next to any group. The form is the same as create; picker checkboxes reflect current per-PBX enabled state. Save updates:

Content changes propagate to every PBX in the group. There is no per-PBX schedule/command divergence — that would be an individual job, not a bulk one.

Deleting

Bulk view → Edit → Delete bulk job. Removes every row of the group across the whole fleet atomically (one DELETE ... WHERE group_id = ?). Confirmation dialog gates the click.

Auto-replicate on new pair

When pair_provisioner successfully pairs a new PBX, the sidecar iterates every existing bulk group and inserts a fresh row for the new PBX with enabled=1. This happens before the pair-complete audit row lands, so any job created "for the fleet" today automatically covers every PBX added tomorrow — no operator action needed.

Skipped on the soft-pair repair path: repair preserves the existing pbxes.uuid FK reference, so already-inserted scheduled_jobs rows stay valid.

Individual cron jobs

Per-PBX Cron tab → + New Individual Cron. The form drawer opens inline — same schedule / command / notes fields as the bulk form, no PBX picker (the tab you're on IS the target).

Individual jobs are standalone. Editing changes only this one PBX's row. Deleting removes only this row. They're invisible from the bulk Cron list — that view shows bulk-origin groups only.

Per-PBX Cron tab

/pbx/{uuid}/cron — operator role required. Two stacked sections:

  1. Individual cron jobs (top) — this PBX's individual-origin rows. Each row has Enable/Disable + Edit + Delete. The + New Individual Cron button opens the inline drawer.
  2. Bulk cron jobs (bottom) — this PBX's assignment for every bulk group. Read-only except for the Enable/Disable toggle (which flips this PBX's row without touching the rest of the group). No Edit, no Delete — content changes require the bulk view; removing a bulk job requires deleting the whole group from the bulk view.

Below both sections, a Recent runs table shows the last 20 executions on this PBX (both origins mixed, ordered by start time). Each row has:

Permission matrix at a glance:

Action Bulk-origin row (per-PBX view) Individual-origin row (per-PBX view)
Enable / Disable
Edit schedule / command / notes ❌ (use bulk view)
Delete ❌ (use bulk view — deletes whole group)

Cron expression reference

Standard 5-field Vixie-cron format:

* * * * *  command
│ │ │ │ │
│ │ │ │ └── day-of-week   (0-7,   0 or 7 = Sunday)
│ │ │ └──── month         (1-12)
│ │ └────── day-of-month  (1-31)
│ └──────── hour          (0-23)
└────────── minute        (0-59)

Supported syntax in each field:

Range validation is enforced server-side (both PHP form validator and the sidecar Python parser). 60 * * * * is rejected before persist; * 24 * * * is rejected; 0 0 32 * * is rejected.

DOM/DOW OR-semantics

When both day-of-month and day-of-week are restricted (neither is *), Vixie-cron fires when either matches — not both. For example, 0 2 15 * 1 fires at 02:00 on the 15th of any month or on any Monday. If you want strict AND, use * in one of the two fields and filter with a wrapper.

Wildcard-in-one field falls through to standard AND semantics: 0 2 * * 1 fires at 02:00 every Monday (day-of-month is *, day-of-week matches).

Timezone

Schedules are interpreted in the manager's system timezone, not the PBX's. Set the manager's timezone during install (see TIMEZONE.md); it's pinned across the OS, PHP, and MariaDB so cron fires consistently.

For multi-tenant operators running PBXes in mixed timezones, this matters — a bulk job scheduled 0 2 * * * in a manager on America/Chicago fires at 02:00 CT, which is a different wall-clock time on a PBX in Europe/Warsaw. Consider adding a note on the job explaining the intended fire time in each region's local time.

Common recipes

Intent Expression
Every minute * * * * *
Every 5 minutes */5 * * * *
Every hour at :00 0 * * * *
Every day at 02:30 30 2 * * *
Every Sunday at 04:00 0 4 * * 0
Weekdays 09:00-17:00 hourly 0 9-17 * * 1-5
First of the month at midnight 0 0 1 * *
Every 15 min during business hours */15 8-18 * * 1-5
Quarter-hourly 0,15,30,45 * * * *

Execution model

Logging + audit

Three places to look:

1. scheduled_job_runs DB table — every dispatch inserts a row with started_at (set synchronously when the tick fires), then UPDATEs with finished_at, exit_code, stdout, stderr (truncated to 4 KB each), and duration_ms on completion. Truncated stdout/stderr are shown in the per-PBX Cron tab's "Recent runs" table under <details> collapsibles.

2. Sidecar log (/var/log/customsso-manager/sidecar.log) — per-dispatch lines in the same format as bulk-command dispatches:

2026-08-17 18:36:01,731 INFO cron: 1 due at 2026-08-17 18:36
2026-08-17 18:36:01,764 INFO cron: dispatch job=6 pbx=pbx107test cmd='uptime'
2026-08-17 18:36:01,947 INFO cron: done    job=6 pbx=pbx107test exit=0 took=170ms

tail -f /var/log/customsso-manager/sidecar.log | grep cron: gives a live feed.

3. audit table — every operator action writes an audit row (visible in Settings → Audit log):

Action Written on
cron.bulk_create New bulk group created via bulk form
cron.bulk_update Bulk group edited (content or per-PBX enabled map)
cron.bulk_delete Bulk group deleted from bulk view
cron.individual_create New individual job created via per-PBX tab
cron.individual_update Individual job edited
cron.individual_delete Individual job deleted
cron.row_enable Per-PBX Enable clicked on any row
cron.row_disable Per-PBX Disable clicked on any row
cron.auto_replicate Bulk rows auto-inserted for a newly-paired PBX

Each row carries user_id (who did it), pbx_uuid (which PBX if applicable), target (group_id or row id), success (bool), and a detail JSON with schedule + command excerpt + counts.

Failure modes + troubleshooting

Job never fires. Check in this order:

  1. Row is enabled — SELECT id, schedule, enabled, last_run_at FROM scheduled_jobs WHERE id = ?. enabled=0 = paused. Toggle via per-PBX Cron tab.
  2. Schedule is valid — try re-saving from the UI; the validator rejects bad expressions on save.
  3. Sidecar is running — systemctl status customsso-sidecar.
  4. Sidecar log shows tick activity — grep "cron:" /var/log/customsso-manager/sidecar.log | tail. If you see cron: N due at ... but not your job, the schedule doesn't match the current time.
  5. Timezone confusion — sidecar tick uses manager's system TZ. Compare date on the manager to what you expect the schedule to fire.

Job fires but returns non-zero exit. Open the per-PBX Cron tab → Recent runs → expand stderr under the offending row. Common causes:

Job fires but stdout/stderr are empty. The command probably closed both descriptors before writing (e.g. command > /dev/null 2>&1). Remove the redirect if you want the output captured.

"Bulk job" button greyed out on per-PBX tab. Expected — bulk jobs are edited/deleted from the bulk view only. Per-PBX view can only Enable/Disable bulk rows.

Sidecar was down at the scheduled fire time. No back-fill; the job simply doesn't run for that window. Same as system cron. If you need back-fill semantics, use anacron on the PBX itself instead.

scheduled_job_runs filling up. Currently no vacuum. If you have high-frequency jobs (* * * * *) accumulating over months, run manually:

DELETE FROM scheduled_job_runs WHERE started_at < NOW() - INTERVAL 90 DAY;

A vacuum step is planned for a future release.

Security

FAQ

Written question-first for search engines + AI answer boxes. Every answer is standalone (no "see above").

Can I schedule a command to run on all FreePBX servers at once?

Yes. Create a bulk cron job from the Bulk actions → Cron tab in customsso-manager v1.0.3+. The job automatically runs on every currently-paired PBX and auto-extends to any newly-added PBX. Uncheck specific PBXes in the create form to have the row created disabled on those PBXes (still there, just paused; can be re-enabled later).

Can I schedule a command per PBX independently?

Yes. Open the PBX's detail page → Cron tab → + New Individual Cron. The job exists only for that PBX, has no siblings, and can be edited or deleted from the same tab. Individual jobs never appear in the bulk view.

What happens to my scheduled jobs when I add a new PBX to the manager?

Bulk cron jobs automatically extend to the new PBX. The pair-provisioner hook inserts one row per existing bulk group with enabled=1 before returning success from the pair operation. Individual cron jobs stay pinned to their original PBX and don't replicate.

Can I run a cron command every 5 seconds?

No. Manager-side cron has 60-second minimum resolution — the sidecar evaluates schedules at minute precision, so * * * * * fires once per minute. If you need sub-minute cadence, use systemd.timer or a dedicated daemon on the PBX itself.

What timezone does the schedule use?

The manager's system timezone, not the PBX's. If your manager runs America/Chicago and the PBX runs Europe/Warsaw, a schedule of 0 2 * * * fires at 02:00 CT — which is a different wall-clock time on the PBX. See TIMEZONE.md for how to pin the manager's TZ across OS + PHP + MariaDB.

How do I see if my cron job ran?

Three places. Per-PBX Cron tab has a "Recent runs" table at the bottom showing the last 20 executions with exit code, duration, stdout, stderr. Sidecar log (/var/log/customsso-manager/sidecar.log) has cron: dispatch / cron: done lines per fire. Audit table (Settings → Audit log) records every operator action but not every fire — for per-fire history use the runs table.

How do I temporarily pause a bulk cron job on one PBX?

Open that PBX's Cron tab → find the job in the "Bulk cron jobs" section → click Disable. This flips the enabled flag on this PBX's row only; other PBXes in the group keep running the job. Click Enable to resume.

How does this compare to per-PBX crontab entries?

Manager-side cron dispatches from the manager over SSH. No /etc/crontab or /var/spool/cron/* entries land on the PBX. Consequences: (a) unpairing a PBX cleanly stops its scheduled work — no orphan cron entries surviving on the PBX; (b) the manager's system timezone is what matters, not the PBX's; (c) if the manager is down at fire time, the job doesn't run — traditional system cron on the PBX would still run. Pick manager-side cron for fleet consistency + clean unpair semantics. Pick PBX-side cron for jobs that must run even when the manager is offline.

Does this survive manager restarts / crashes?

Yes. Schedules live in MariaDB (scheduled_jobs table); they persist across sidecar restarts, manager reboots, and OS crashes. The sidecar picks up the current state on start and evaluates every enabled row against the current wall-clock minute on its next tick.

Does this survive PBX restarts?

Yes. There's nothing installed on the PBX for the cron feature — no crontab entries, no local state. The next scheduled fire attempts an SSH connect; if the PBX is booting, that fire logs a connect error and the next scheduled fire tries again.

Can I back up / migrate my cron jobs?

Yes — they're rows in the manager's MariaDB scheduled_jobs and scheduled_job_runs tables. The encrypted-bundle backup (BACKUP.md) captures the whole database including these tables. Restore + Reclaim on a fresh manager brings the schedules back and the sidecar's next tick resumes firing them.

How do I run a fwconsole command every night on every PBX?

Bulk actions → Cron → + Add Bulk Cron Job. Fill:

Save. The sidecar will dispatch /usr/sbin/fwconsole reload to every paired PBX at 02:00 manager time from tonight onwards. Any PBX added later automatically starts running this job on its 02:00.