Initial code commit
CI / test (3.11) (push) Canceled after 0s
CI / test (3.12) (push) Canceled after 0s
CI / test (3.13) (push) Canceled after 0s
CI / test (3.14) (push) Canceled after 0s
CI / package (push) Canceled after 0s
CI / precommit-and-security (push) Canceled after 11s
CI / typecheck (push) Canceled after 11s
CI / test (3.11) (push) Canceled after 0s
CI / test (3.12) (push) Canceled after 0s
CI / test (3.13) (push) Canceled after 0s
CI / test (3.14) (push) Canceled after 0s
CI / package (push) Canceled after 0s
CI / precommit-and-security (push) Canceled after 11s
CI / typecheck (push) Canceled after 11s
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# Architecture
|
||||
|
||||
`schedls` is a thin CLI over two native scheduling backends. It holds no state
|
||||
of its own: the scheduler configuration on the host is the source of truth.
|
||||
|
||||
```text
|
||||
parse args
|
||||
|
|
||||
v
|
||||
validate request
|
||||
|
|
||||
v
|
||||
operation / service layer operations/
|
||||
|
|
||||
v
|
||||
backend backends/systemd.py, backends/cron.py
|
||||
|
|
||||
v
|
||||
result model models.py
|
||||
|
|
||||
v
|
||||
formatter output.py
|
||||
```
|
||||
|
||||
## Package layout
|
||||
|
||||
```text
|
||||
src/schedls/
|
||||
__init__.py version
|
||||
__main__.py python -m schedls
|
||||
cli.py argparse, dispatch, request building
|
||||
errors.py exception hierarchy and exit codes
|
||||
models.py common model (jobs, specs, schedules, commands)
|
||||
output.py human tables, key/value views, JSON schema
|
||||
runner.py the only place subprocesses are executed
|
||||
security.py name validation, trusted paths, atomic writes
|
||||
timefmt.py durations, timestamps, formatting
|
||||
describe.py presentation-only schedule descriptions
|
||||
convenience.py convenience flags -> native expressions
|
||||
unitfile.py read-only systemd unit-file parser
|
||||
interact.py confirmation handling
|
||||
prompt.py opt-in interactive input collection (wizard)
|
||||
backends/
|
||||
base.py capability model, plans, mutation results
|
||||
systemd.py discovery + transactional mutation
|
||||
cron.py lossless crontab model + discovery + mutation
|
||||
renderers/
|
||||
systemd.py ExecStart quoting, unit rendering, ExecStart parsing
|
||||
cron.py shell-safe command rendering, managed blocks
|
||||
operations/
|
||||
inspect.py collect, filter, list, show
|
||||
calendar.py schedls calendar
|
||||
doctor.py schedls doctor
|
||||
mutate.py preview, confirm, apply
|
||||
```
|
||||
|
||||
## Layers
|
||||
|
||||
- **Backends** implement discovery and mutation against native facilities and
|
||||
never format output.
|
||||
- **Renderers** are pure functions from an internal model to native text. They
|
||||
are the most security-sensitive code and are covered by exhaustive tests.
|
||||
- **Operations** orchestrate: gather, preview, confirm, apply.
|
||||
- **CLI** translates arguments into a request, calls one operation, and maps
|
||||
errors to exit codes. With `--interactive` it first fills missing fields via
|
||||
**prompt** (line-based, terminal-only), then continues down the normal path.
|
||||
- **Runner** is the single process-execution boundary. `shell=True` never
|
||||
appears in production code.
|
||||
|
||||
## Mutation plans
|
||||
|
||||
Mutating commands do not write directly. A backend produces a `Plan`:
|
||||
|
||||
```text
|
||||
Plan
|
||||
action create | update | remove | enable | disable
|
||||
summary key/value lines for the preview
|
||||
files FileChange(path, content | None)
|
||||
commands CommandPlan(argv, description, input_text)
|
||||
warnings messages to show the user
|
||||
payload backend-private state (scope, unit names, snapshots)
|
||||
```
|
||||
|
||||
The caller previews the plan, obtains confirmation, then calls `apply`. Plans
|
||||
snapshot existing content so a failed step can be rolled back. Cron plans also
|
||||
record the previous crontab text and re-check it at apply time to detect
|
||||
concurrent edits.
|
||||
|
||||
## Exit codes
|
||||
|
||||
```text
|
||||
0 success
|
||||
1 operational failure
|
||||
2 invalid command-line input or invalid schedule
|
||||
3 safety refusal / conflict
|
||||
```
|
||||
|
||||
## JSON schema
|
||||
|
||||
`schedls --json` emits a single document on stdout; diagnostics go to stderr.
|
||||
The schema version is `1`.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"jobs": [
|
||||
{
|
||||
"name": "backup",
|
||||
"backend": "systemd",
|
||||
"scope": "user",
|
||||
"managed": true,
|
||||
"enabled": true,
|
||||
"schedule": {"kind": "calendar", "expression": "*-*-* 02:00:00"},
|
||||
"command": {"argv": ["/usr/local/bin/backup"], "shell": false, "raw": null},
|
||||
"source": {"detail": "systemd user timer", "path": "...", "line": null},
|
||||
"next_run": "2026-09-25T02:00:00+01:00",
|
||||
"last_run": null,
|
||||
"last_result": null,
|
||||
"warnings": []
|
||||
}
|
||||
],
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
Rules: no ANSI sequences; ISO 8601 timestamps with explicit offsets; stable
|
||||
field names; missing data is `null`, never invented.
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
# Command-line reference
|
||||
|
||||
Complete reference for every `schedls` command, option and exit code. For
|
||||
background on the backends see [systemd.md](systemd.md) and [cron.md](cron.md);
|
||||
for the JSON document layout see [architecture.md](architecture.md#json-schema).
|
||||
|
||||
## Synopsis
|
||||
|
||||
```text
|
||||
schedls [GLOBAL OPTIONS] [COMMAND] [COMMAND OPTIONS] [-- COMMAND ARGS...]
|
||||
```
|
||||
|
||||
With no command, `schedls` behaves like `schedls list`.
|
||||
|
||||
## Global options
|
||||
|
||||
Global options may appear before the command. `--user`/`--system`,
|
||||
`--backend`, `--managed`/`--unmanaged` and `--enabled`/`--disabled` filter what
|
||||
`list` discovers and prints.
|
||||
|
||||
| Option | Meaning |
|
||||
|-------------------------|---------------------------------------------------------------------|
|
||||
| `--version` | print the version and exit |
|
||||
| `--debug` | print helper commands and diagnostics on stderr |
|
||||
| `--json` | emit a single JSON document on stdout (schema version 1) |
|
||||
| `--color auto\|always\|never` | colorize human output (default `auto`; `NO_COLOR` disables) |
|
||||
| `--utc` | display all times in UTC instead of local time |
|
||||
| `--user` / `--system` | only show user-scope / system-scope jobs |
|
||||
| `--backend systemd\|cron` | only show jobs from one backend |
|
||||
| `--managed` / `--unmanaged` | only schedls-managed / only unmanaged jobs |
|
||||
| `--enabled` / `--disabled` | only enabled / only disabled jobs |
|
||||
|
||||
`--json` is intended for scripts. It prints one document on stdout and sends
|
||||
diagnostics to stderr; no ANSI escapes are emitted.
|
||||
|
||||
## Commands
|
||||
|
||||
### `list`
|
||||
|
||||
```console
|
||||
$ schedls
|
||||
$ schedls --system
|
||||
$ schedls --backend cron
|
||||
$ schedls --unmanaged --json
|
||||
```
|
||||
|
||||
Lists visible scheduled jobs. Columns are `NAME`, `SCHEDULE`, `NEXT`, `BACKEND`,
|
||||
`SCOPE`, `STATUS`. A missing next run is shown as `—`. Discovery warnings are
|
||||
written to stderr as `Note:` lines.
|
||||
|
||||
### `show NAME`
|
||||
|
||||
```console
|
||||
$ schedls show backup
|
||||
```
|
||||
|
||||
Shows one job in detail: status, backend and scope, whether schedls manages it,
|
||||
the schedule, the next and previous runs, the command, the source paths, and any
|
||||
systemd extras (persistent, jitter, accuracy, working directory, environment).
|
||||
|
||||
### `new NAME`
|
||||
|
||||
```console
|
||||
$ schedls new backup --timer --daily 02:00 --persistent -- /usr/local/bin/backup /srv/data
|
||||
$ schedls new cleanup --cron --cron-expr '0 4 * * 0' -- /usr/local/bin/cleanup
|
||||
$ schedls new backup -i
|
||||
```
|
||||
|
||||
Creates a scheduled job. `NAME` and a backend (`--timer` or `--cron`) are
|
||||
required unless `--interactive` is used.
|
||||
|
||||
The command to run is everything after `--`:
|
||||
|
||||
```console
|
||||
$ schedls new backup --timer --daily 02:00 -- /usr/local/bin/backup "/srv/my data"
|
||||
```
|
||||
|
||||
Arguments after `--` are passed literally as an argv; `schedls` does not
|
||||
interpret `|`, `>`, `$()` or `$VAR`. Use `--shell SCRIPT` instead when you
|
||||
explicitly want `/bin/sh -c`:
|
||||
|
||||
```console
|
||||
$ schedls new rotate --timer --daily 03:00 --shell 'find /tmp -mtime +7 -delete'
|
||||
```
|
||||
|
||||
`--shell` and a command after `--` are mutually exclusive.
|
||||
|
||||
### `edit NAME`
|
||||
|
||||
```console
|
||||
$ schedls edit backup --daily 03:00
|
||||
$ schedls edit backup --jitter 5min --no-persistent
|
||||
$ schedls edit backup --command -- /usr/local/bin/backup /srv/data
|
||||
$ schedls edit backup -i
|
||||
```
|
||||
|
||||
Changes a job created by `schedls`. Only the options you pass are changed;
|
||||
everything else keeps its current value.
|
||||
|
||||
- To replace the command, pass `--command` before the new command after `--`.
|
||||
Passing `--` without `--command` is an error, so the command can never be
|
||||
changed by accident.
|
||||
- `--no-persistent` clears `Persistent=` on a systemd timer.
|
||||
- Editing is supported for systemd timers only. Editing cron jobs is not yet
|
||||
supported and reports a usage error.
|
||||
|
||||
### `rm NAME`
|
||||
|
||||
```console
|
||||
$ schedls rm backup
|
||||
$ schedls rm backup --dry-run
|
||||
```
|
||||
|
||||
Removes a schedls-managed job and its files. Unmanaged jobs cannot be removed.
|
||||
|
||||
### `enable NAME` / `disable NAME`
|
||||
|
||||
```console
|
||||
$ schedls enable backup
|
||||
$ schedls disable backup
|
||||
```
|
||||
|
||||
Enable or disable a systemd timer. Cron has no portable enabled/disabled
|
||||
concept, so these commands explain that limitation and exit without changing
|
||||
anything.
|
||||
|
||||
### `logs NAME`
|
||||
|
||||
```console
|
||||
$ schedls logs backup
|
||||
$ schedls logs backup --lines 200
|
||||
$ schedls logs backup --since '2026-09-01' --lines 500
|
||||
```
|
||||
|
||||
Shows recent journal output for a systemd timer's service unit. The command is
|
||||
never re-executed; this only reads the journal through `journalctl --no-pager`.
|
||||
|
||||
| Option | Default | Meaning |
|
||||
|----------------|---------|-------------------------------------------------------------------------|
|
||||
| `--lines N` | `50` | number of recent journal entries to show (`journalctl --lines=N`); `N` must be between `1` and `1000000` |
|
||||
| `--since TIME` | none | only entries since `TIME`, passed verbatim to `journalctl --since` |
|
||||
|
||||
`TIME` accepts anything `journalctl --since` accepts, such as
|
||||
`'2026-09-01'`, `'2 hours ago'` or `'2026-09-01 10:00:00'`. `--since` bounds the
|
||||
range and `--lines` caps how many entries within it are shown, so raise
|
||||
`--lines` to see more of a longer history.
|
||||
|
||||
Journal output is untrusted program output. When stdout is a terminal, control
|
||||
characters are escaped (`\x1b` and friends) so a log line cannot inject terminal
|
||||
escapes; when output is redirected to a file or pipe the bytes are passed
|
||||
through unchanged. With `--json`, the command emits a document instead of raw
|
||||
text:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "backup",
|
||||
"backend": "systemd",
|
||||
"unit": "schedls-backup.service",
|
||||
"content": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Cron jobs have no portable per-job log interface. For a cron job, `schedls logs`
|
||||
explains this (cron may mail output, redirect it, or write to the system log)
|
||||
and exits `0`.
|
||||
|
||||
### `calendar EXPR`
|
||||
|
||||
```console
|
||||
$ schedls calendar 'Mon..Fri 02:30'
|
||||
$ schedls calendar --next 10 --utc daily
|
||||
```
|
||||
|
||||
Validates a systemd `OnCalendar` expression with `systemd-analyze calendar` and
|
||||
prints its normalized form and upcoming occurrences. Nothing is created.
|
||||
|
||||
| Option | Default | Meaning |
|
||||
|-------------|---------|-------------------------------------|
|
||||
| `--next N` | `5` | how many upcoming occurrences to show |
|
||||
|
||||
Requires `systemd-analyze`. Without it the command reports a missing dependency.
|
||||
|
||||
### `doctor`
|
||||
|
||||
```console
|
||||
$ schedls doctor
|
||||
```
|
||||
|
||||
Reports which native facilities are usable on this host: systemd manager
|
||||
availability, `systemd-analyze`, user lingering, `crontab` availability, the
|
||||
detected cron implementation, whether the current user may use cron, and
|
||||
whether the local `crontab` supports syntax validation. It only inspects; it
|
||||
never changes anything.
|
||||
|
||||
## Schedule options
|
||||
|
||||
`new` and `edit` accept the same schedule options. Convenience flags compile to
|
||||
native syntax; see the per-backend tables in [systemd.md](systemd.md#calendar-expressions)
|
||||
and [cron.md](cron.md#schedule-shortcuts).
|
||||
|
||||
| Option | Backend | Example |
|
||||
|-------------------------|----------------|----------------------------------|
|
||||
| `--calendar EXPR` | systemd only | `--calendar 'Mon..Fri 02:30'` |
|
||||
| `--daily TIME` | both | `--daily 02:00` |
|
||||
| `--weekdays TIME` | both | `--weekdays 08:30` |
|
||||
| `--weekly DAY TIME` | both | `--weekly sun 04:00` |
|
||||
| `--monthly DAY TIME` | both | `--monthly 15 06:00` |
|
||||
| `--cron-expr EXPR` | cron only | `--cron-expr '0 4 * * 0'` |
|
||||
| `--persistent` | systemd only | run events missed while powered off |
|
||||
| `--jitter DURATION` | systemd only | `--jitter 5min` |
|
||||
| `--accuracy DURATION` | systemd only | `--accuracy 1min` |
|
||||
| `--working-directory PATH` | systemd only | `--working-directory /srv/data` |
|
||||
| `--env KEY=VALUE` | systemd only | `--env TZ=UTC` (repeatable) |
|
||||
| `--shell SCRIPT` | both | `--shell 'echo hi \| tee /tmp/log'` |
|
||||
|
||||
Notes:
|
||||
|
||||
- `--calendar` may be repeated; systemd supports multiple `OnCalendar=` entries.
|
||||
- Do not combine `--calendar`/`--cron-expr` with the convenience flags; `schedls`
|
||||
rejects mixing them rather than guessing which you meant.
|
||||
- Backend-specific options are rejected for the wrong backend (for example
|
||||
`--cron-expr` with `--timer`, or `--persistent` with `--cron`) instead of
|
||||
being silently ignored.
|
||||
- Cron accepts a single schedule.
|
||||
- Cron environment variables are not supported yet; passing `--env` with
|
||||
`--cron` is rejected rather than silently ignored.
|
||||
- Cron jobs are always created for the current user. `--system` selects system
|
||||
scope for systemd timers and is rejected for cron.
|
||||
- `new --timer` defaults to user scope. System scope requires appropriate
|
||||
privileges; `schedls` never runs `sudo` for you.
|
||||
|
||||
## Mutation options
|
||||
|
||||
These options apply to `new`, `edit`, `rm`, `enable` and `disable`:
|
||||
|
||||
| Option | Meaning |
|
||||
|----------------|-------------------------------------------------------------------------|
|
||||
| `--dry-run` | print the plan and exit without changing anything |
|
||||
| `--yes` | do not ask for confirmation (for scripts) |
|
||||
| `--show-files` | include the rendered file contents in the preview |
|
||||
| `-i, --interactive` | collect missing fields with guided prompts (see below) |
|
||||
|
||||
Every mutation prints a preview of exactly what will be written (paths, and with
|
||||
`--show-files` the contents) and asks for confirmation before applying. Without
|
||||
a terminal, confirmation fails unless `--yes` is passed.
|
||||
|
||||
## Interactive mode
|
||||
|
||||
`new` and `edit` accept `-i`/`--interactive` to fill missing fields with guided,
|
||||
line-based prompts. Provided flags act as pre-filled defaults and are not
|
||||
re-asked.
|
||||
|
||||
```console
|
||||
$ schedls new backup -i
|
||||
$ schedls edit backup -i
|
||||
```
|
||||
|
||||
- Prompts use stdin only; no external editor or pager is ever launched.
|
||||
- A terminal is required. With piped or redirected input, `schedls` exits `3`
|
||||
and asks you to pass flags instead.
|
||||
- Interactive mode cannot be combined with `--json` (exit `2`).
|
||||
- Collected values still go through the normal preview and confirmation, so
|
||||
nothing is written without showing you the resolved schedule and files first.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|--------------------------------------------------------------|
|
||||
| `0` | success |
|
||||
| `1` | operational failure (helper command, filesystem, discovery) |
|
||||
| `2` | invalid command-line input or invalid schedule |
|
||||
| `3` | safety refusal / conflict (unmanaged object, concurrency, no TTY for confirmation) |
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Effect |
|
||||
|------------|----------------------------------------------------|
|
||||
| `NO_COLOR` | when set, disables color even with `--color auto` |
|
||||
|
||||
Helper commands run with a controlled environment (`LC_ALL=C`,
|
||||
`SYSTEMD_COLORS=0`, `SYSTEMD_PAGER=cat`, `PAGER=cat`) and are never run through
|
||||
a shell.
|
||||
@@ -0,0 +1,90 @@
|
||||
# cron backend
|
||||
|
||||
`schedls` reads and edits the **current user's crontab** through the `crontab`
|
||||
utility. It never writes to `/var/spool/cron*` directly.
|
||||
|
||||
## Reading
|
||||
|
||||
The crontab is read with `crontab -l` and parsed losslessly into lines:
|
||||
|
||||
- blank lines;
|
||||
- comments;
|
||||
- environment assignments (`KEY=VALUE`);
|
||||
- five-field entries and `@` nicknames;
|
||||
- `schedls:begin` / `schedls:end` managed-block markers.
|
||||
|
||||
Every unrelated line is preserved exactly. `schedls` does not reformat, sort,
|
||||
normalize, or rewrite crontab content it did not create.
|
||||
|
||||
## Managed blocks
|
||||
|
||||
Jobs created by `schedls` live in a delimited block:
|
||||
|
||||
```cron
|
||||
# schedls:begin name=backup
|
||||
0 2 * * * /usr/local/bin/backup /srv/data
|
||||
# schedls:end name=backup
|
||||
```
|
||||
|
||||
If markers are malformed or a `begin` has no matching `end`, mutations refuse
|
||||
to proceed rather than guess.
|
||||
|
||||
## Command rendering
|
||||
|
||||
Cron executes command text through a shell. `schedls` keeps an argv internally
|
||||
and serializes it safely:
|
||||
|
||||
1. each argument is quoted for `/bin/sh` (`shlex.quote`);
|
||||
2. every `%` is escaped as `\%`, because cron implementations such as Cronie
|
||||
treat an unescaped `%` as a newline.
|
||||
|
||||
Arguments containing NUL or newline are rejected, because no safe single-line
|
||||
representation exists.
|
||||
|
||||
Raw shell syntax is available explicitly with `--shell`. `schedls` never infers
|
||||
shell mode from characters such as `|`, `>`, `&&` or `$()`.
|
||||
|
||||
## Schedule shortcuts
|
||||
|
||||
| Flag | Example | Compiles to |
|
||||
|-------------------|-----------------------|-------------------|
|
||||
| `--daily TIME` | `--daily 02:00` | `0 2 * * *` |
|
||||
| `--weekdays TIME` | `--weekdays 08:30` | `30 8 * * 1-5` |
|
||||
| `--weekly D TIME` | `--weekly sun 04:00` | `0 4 * * 0` |
|
||||
| `--monthly D TIME`| `--monthly 15 06:00` | `0 6 15 * *` |
|
||||
| `--cron-expr EXPR`| `--cron-expr '0 4 * * 0'` | used verbatim |
|
||||
|
||||
## Installation and validation
|
||||
|
||||
When the local `crontab` supports syntax testing (`crontab -T`), the new
|
||||
crontab is validated before installation. The native `crontab` install remains
|
||||
authoritative. The previous crontab text is retained and restored if
|
||||
installation fails or the crontab changed since the plan was prepared.
|
||||
|
||||
Capabilities are feature-detected, never assumed:
|
||||
|
||||
```python
|
||||
CronCapabilities(
|
||||
supports_validation=True,
|
||||
supports_user_selection=False,
|
||||
implementation="Cronie",
|
||||
)
|
||||
```
|
||||
|
||||
## Enable / disable
|
||||
|
||||
Cron has no universal native enabled/disabled concept. `schedls enable` and
|
||||
`schedls disable` support systemd only and explain this limitation for cron.
|
||||
|
||||
## Logs
|
||||
|
||||
There is no portable per-job log interface for cron. `schedls logs` says so
|
||||
rather than pretending otherwise. `--lines` and `--since` are accepted for
|
||||
command-line compatibility but have no effect on cron jobs; see
|
||||
[cli.md](cli.md#logs-name).
|
||||
|
||||
## Not yet supported
|
||||
|
||||
- system cron (`/etc/crontab`, `/etc/cron.d`, periodic directories);
|
||||
- editing existing cron jobs;
|
||||
- cross-user crontabs.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Security model
|
||||
|
||||
This document describes what `schedls` trusts, what it does not, and how the
|
||||
main threats are mitigated. It complements [SECURITY.md](../SECURITY.md).
|
||||
|
||||
## Trust model
|
||||
|
||||
- **Native schedulers are authoritative.** systemd and cron own execution.
|
||||
`schedls` only reads and writes their configuration.
|
||||
- **Discovered configuration is data.** Commands and schedules found on the
|
||||
host are parsed and displayed, never executed.
|
||||
- **Unmanaged objects are protected.** A timer or cron entry not created by
|
||||
`schedls` cannot be modified or removed by default.
|
||||
- **The user chooses privilege.** `schedls` never escalates on the user's
|
||||
behalf.
|
||||
|
||||
## Threats and mitigations
|
||||
|
||||
### Malicious schedule names
|
||||
|
||||
Names must match `[A-Za-z0-9][A-Za-z0-9_.-]{0,63}`. Path separators, traversal,
|
||||
whitespace and shell metacharacters are rejected. Names are never interpolated
|
||||
into shell strings.
|
||||
|
||||
### Malicious command arguments
|
||||
|
||||
Arguments are kept as an argv internally. systemd units are rendered with a
|
||||
dedicated serializer that always quotes arguments and escapes backslash,
|
||||
double quotes, literal `$` and `%`; arguments containing NUL or newline are
|
||||
rejected. cron commands are serialized with shell quoting and then have every
|
||||
`%` escaped for cron's own parser. Calendar expressions are validated with
|
||||
`systemd-analyze` and must not contain control characters or begin with `-`;
|
||||
timer durations (`--jitter`, `--accuracy`) are validated with
|
||||
`systemd-analyze timespan`. If a safe representation cannot be guaranteed,
|
||||
creation is refused.
|
||||
|
||||
### Symlink attacks during privileged writes
|
||||
|
||||
Before replacing a file, `schedls` uses `lstat`, rejects symlinks and
|
||||
non-regular files, and verifies ownership. Destination directories must be
|
||||
real directories owned by the expected user (`root` when running as root) and
|
||||
must not be group- or other-writable. Temporary files are created in the same
|
||||
directory and atomically renamed.
|
||||
|
||||
### PATH hijacking
|
||||
|
||||
Critical helpers (`systemctl`, `systemd-analyze`, `journalctl`, `crontab`,
|
||||
`loginctl`) are resolved once with a controlled lookup. A helper is rejected
|
||||
unless the executable **and** its realpath target are regular files, are owned
|
||||
by root (when `schedls` runs as root) or by root or the current user, and live
|
||||
in a directory that is not group- or other-writable. Helper processes run with
|
||||
a fixed `PATH` rather than the caller's.
|
||||
|
||||
### Path traversal through discovered unit names
|
||||
|
||||
A `Unit=` value read from a timer file is treated as untrusted data. Mutation
|
||||
never uses it to build a filesystem path: unit names are always derived from
|
||||
the validated job name (`schedls-<name>.timer` / `.service`). Generated unit
|
||||
names are re-validated before they are written to a temporary directory, so a
|
||||
crafted unit cannot redirect writes or removals outside the trusted unit
|
||||
directory.
|
||||
|
||||
### Untrusted output
|
||||
|
||||
`journalctl` output is program output, not configuration. When stdout is a
|
||||
terminal, control characters are escaped before display so a log line cannot
|
||||
inject terminal escape sequences; redirected output is passed through
|
||||
unchanged. Machine-readable `--json` output escapes control characters as part
|
||||
of JSON encoding.
|
||||
|
||||
### Environment injection and pagers
|
||||
|
||||
Helper commands run with a controlled environment (`LC_ALL=C`,
|
||||
`SYSTEMD_COLORS=0`, `SYSTEMD_PAGER=cat`, `PAGER=cat`) and never with a shell.
|
||||
Pagers and editors are never launched while `schedls` is running. Environment
|
||||
values passed with `--env` are written to unit files with systemd quoting and
|
||||
are world-readable like any other unit; do not put secrets in them.
|
||||
|
||||
### TOCTOU races
|
||||
|
||||
Plans snapshot existing content. At apply time the current content is compared
|
||||
against the snapshot; a mismatch aborts the operation. Privileged critical
|
||||
sections are kept small and use file descriptors / atomic rename where
|
||||
practical.
|
||||
|
||||
### Corrupt or malicious existing configuration
|
||||
|
||||
Configuration is treated as opaque data. Malformed `schedls` cron markers cause
|
||||
mutation to fail closed rather than guess. Unknown crontab lines are preserved
|
||||
byte-for-byte. A systemd unit whose name starts with `schedls-` is only treated
|
||||
as managed when its file carries the `# Managed by schedls` and `# Name:`
|
||||
markers, so a same-named foreign unit is not modified.
|
||||
|
||||
### Partial operations
|
||||
|
||||
systemd creation writes both units, reloads the manager, and enables the timer;
|
||||
if a later step fails, or the operation is interrupted, the previous state is
|
||||
restored and a partially enabled timer is disabled again. Cron installs are
|
||||
validated first and restored from the previous crontab text on failure.
|
||||
|
||||
### Supply chain
|
||||
|
||||
Runtime dependencies are zero. Development dependencies are locked and audited
|
||||
in CI.
|
||||
|
||||
## Availability, privacy
|
||||
|
||||
`0.1.0` requires `systemd-analyze` to create or validate calendar expressions
|
||||
and refuses the operation if it is unavailable. `schedls` makes no network
|
||||
requests and collects no telemetry.
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
# systemd backend
|
||||
|
||||
`schedls` manages systemd **timers**. A timer is always paired with a
|
||||
oneshot service that holds the command.
|
||||
|
||||
## Layout
|
||||
|
||||
| Scope | Unit directory | Manager |
|
||||
|--------|------------------------------|----------------------|
|
||||
| user | `~/.config/systemd/user` | `systemctl --user` |
|
||||
| system | `/etc/systemd/system` | `systemctl` |
|
||||
|
||||
`new --timer` defaults to user scope. System scope must be requested
|
||||
explicitly with `--system` and appropriate privileges; `schedls` never invokes
|
||||
`sudo` for you.
|
||||
|
||||
## Created files
|
||||
|
||||
For a job named `backup`:
|
||||
|
||||
```ini
|
||||
# ~/.config/systemd/user/schedls-backup.service
|
||||
# Managed by schedls
|
||||
# Name: backup
|
||||
|
||||
[Unit]
|
||||
Description=schedls job backup
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart="/usr/local/bin/backup" "/srv/data"
|
||||
```
|
||||
|
||||
```ini
|
||||
# ~/.config/systemd/user/schedls-backup.timer
|
||||
# Managed by schedls
|
||||
# Name: backup
|
||||
|
||||
[Unit]
|
||||
Description=schedls job backup (timer)
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 02:00:00
|
||||
Persistent=true
|
||||
Unit=schedls-backup.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
```
|
||||
|
||||
The `schedls-` prefix and the header comments provide provenance without a
|
||||
database.
|
||||
|
||||
## Argument handling
|
||||
|
||||
`ExecStart=` is not shell syntax. `schedls` serializes each argument with
|
||||
systemd's own quoting rules and always quotes arguments, so a value such as
|
||||
`$(...)`, `%i`, `$HOME` or an argument beginning with `-` is passed literally.
|
||||
Use `--shell` to explicitly request `/bin/sh -c`.
|
||||
|
||||
Every rendered unit is validated with `systemd-analyze verify` before
|
||||
installation.
|
||||
|
||||
## Calendar expressions
|
||||
|
||||
Convenience flags compile to native `OnCalendar=` values:
|
||||
|
||||
| Flag | Example | Compiles to |
|
||||
|------------------|----------------------------|------------------------------------|
|
||||
| `--daily TIME` | `--daily 02:00` | `*-*-* 02:00:00` |
|
||||
| `--weekdays TIME`| `--weekdays 08:30` | `Mon..Fri *-*-* 08:30:00` |
|
||||
| `--weekly D TIME`| `--weekly sun 04:00` | `Sun *-*-* 04:00:00` |
|
||||
| `--monthly D TIME`| `--monthly 1 06:00` | `*-*-01 06:00:00` |
|
||||
| `--calendar EXPR`| `--calendar 'Mon..Fri 02:30'` | used verbatim |
|
||||
|
||||
`--calendar` may be repeated; systemd supports multiple `OnCalendar=` entries.
|
||||
|
||||
## Discovery
|
||||
|
||||
Timers are listed with `systemctl list-unit-files` and `list-units`, then
|
||||
properties are read with `systemctl show`. OnCalendar, Persistent and the
|
||||
command are read from the unit fragments. The next elapse is taken from
|
||||
`NextElapseUSecRealtime`; monotonic timers have no wall-clock next run and are
|
||||
reported as unknown.
|
||||
|
||||
## Logs
|
||||
|
||||
`schedls logs NAME` queries the service unit's journal via `journalctl
|
||||
--no-pager`. The job command is never re-executed.
|
||||
|
||||
Two options control how much is shown, both passed to `journalctl`:
|
||||
|
||||
| Option | Default | Meaning |
|
||||
|----------------|---------|---------------------------------------------------------------|
|
||||
| `--lines N` | `50` | number of recent entries (`journalctl --lines=N`) |
|
||||
| `--since TIME` | none | lower bound on entry time (`journalctl --since=TIME`) |
|
||||
|
||||
`TIME` accepts any value `journalctl --since` accepts, for example
|
||||
`'2026-09-01'`, `'2 hours ago'` or `'2026-09-01 10:00:00'`. `--since` bounds the
|
||||
range and `--lines` caps how many entries within it are shown. See
|
||||
[cli.md](cli.md#logs-name) for the full command reference.
|
||||
|
||||
## Lingering
|
||||
|
||||
User timers only run while the user's systemd manager is alive. `schedls`
|
||||
warns when lingering is disabled but never enables it.
|
||||
Reference in New Issue
Block a user