Skip to content

Commit eed87ee

Browse files
committed
systemd: Execute timer commands via the shell
This is what people probably expect anyway, and it allows us to dig the verbatim command out of the ExecStart D-Bus property.
1 parent 2f32a4b commit eed87ee

File tree

4 files changed

+51
-58
lines changed

4 files changed

+51
-58
lines changed

pkg/systemd/service-details.jsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import './service-details.scss';
4040
import { KebabDropdown } from "cockpit-components-dropdown";
4141

4242
import { TimerDialog } from "./timer-dialog.jsx";
43-
import { from_boot_usec, from_on_calendar, join_command_arguments } from "./timer-dialog-helpers.js";
43+
import { from_boot_usec, from_on_calendar } from "./timer-dialog-helpers.js";
4444

4545
const _ = cockpit.gettext;
4646
const METRICS_POLL_DELAY = 30000; // 30s
@@ -385,15 +385,20 @@ export class ServiceDetails extends React.Component {
385385
const serviceProperties = await bus.call(serviceDbusPath[0], s_bus.I_PROPS, "GetAll", [s_bus.I_SERVICE]);
386386
const exec = serviceProperties[0].ExecStart.v;
387387

388-
// ensure there is exactly one ExecStart= in the unit file
389-
if (exec.length === 1) {
390-
const execCommand = exec[0][0];
391-
const execArguments = exec[0][1];
392-
return await join_command_arguments(execCommand, execArguments);
393-
} else {
388+
// ensure there is exactly one ExecStart= in the unit file that matches "/bin/sh -c CMD"
389+
390+
if (exec.length !== 1) {
394391
console.warn(`${exec.length} ExecStart= entries were found for ${serviceUnitName} instead of 1`);
395392
return null;
396393
}
394+
395+
const [cmd, args] = exec[0];
396+
if (cmd !== "/bin/sh" || args.length !== 3 || args[1] != "-c") {
397+
console.warn(`ExecStart= entry is not of the form "/bin/sh -c CMD"`);
398+
return null;
399+
}
400+
401+
return args[2];
397402
}
398403

399404
const Dialogs = this.context;

pkg/systemd/timer-dialog-helpers.js

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,10 @@
44
*/
55

66
import cockpit from "cockpit";
7-
import * as python from "python";
87
import s_bus from "./busnames.js";
98
import { systemd_client, clock_realtime_now } from "./services.jsx";
109
import { CockpitManagedMarker } from "./service-details.jsx";
1110

12-
export async function join_command_arguments(command, args) {
13-
const exec = [command].concat(args.slice(1));
14-
const shlex = python.spawn(
15-
"from json import loads; from shlex import join; from sys import stdin;" +
16-
"out = join(loads(stdin.read())); print(out, end='');"
17-
);
18-
19-
shlex.input(JSON.stringify(exec));
20-
shlex.input();
21-
const output = await shlex;
22-
23-
return output;
24-
}
25-
2611
export function from_boot_usec(value) {
2712
const result = { delay: "system-boot" };
2813
const seconds = Math.floor(value / 1e6); // Convert from microseconds
@@ -141,12 +126,29 @@ export function from_on_calendar(patterns) {
141126
}
142127
}
143128

129+
/* Escape STR so that it is parsed as a single argument in a
130+
ExecStart= line. We need to escape spaces, newlines, quotes, and
131+
backslashes by prepending a backslash. We also need to escape
132+
specifiers by prepending a "%" character.
133+
*/
134+
135+
function escape_systemd_exec_arg(str) {
136+
return str
137+
.replaceAll("\\", "\\\\")
138+
.replaceAll(" ", "\\s")
139+
.replaceAll("\t", "\\t")
140+
.replaceAll("\n", "\\n")
141+
.replaceAll("\"", "\\\"")
142+
.replaceAll("'", "\\'")
143+
.replaceAll("%", "%%");
144+
}
145+
144146
export function create_timer({ name, description, command, delay, delayUnit, delayNumber, repeat, repeatPatterns, specificTime, owner }) {
145147
const timer_unit = {};
146148
const repeat_array = repeatPatterns;
147149
timer_unit.name = name.replace(/\s/g, '');
148150
timer_unit.Description = description;
149-
timer_unit.Command = command;
151+
timer_unit.Command = "/bin/sh -c " + escape_systemd_exec_arg(command);
150152
timer_unit.boot_time = delayNumber;
151153
timer_unit.boot_time_unit = delayUnit;
152154

pkg/systemd/timer-dialog.jsx

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,13 @@ export const TimerDialog = ({ owner, timer }) => {
6363
const [specificTime, setSpecificTime] = useState(timer?.specificTime || "00:00");
6464
const [isSpecificTimeOpen, setSpecificTimeOpen] = useState(false);
6565
const [submitted, setSubmitted] = useState(false);
66-
const [commandNotFound, setCommandNotFound] = useState(false);
6766
const validationFailed = {};
6867

6968
if (!name.trim().length || !/^[a-zA-Z0-9:_.@-]+$/.test(name))
7069
validationFailed.name = true;
7170
if (!description.trim().length)
7271
validationFailed.description = true;
73-
if (!command.trim().length || commandNotFound)
72+
if (!command.trim().length)
7473
validationFailed.command = true;
7574
if (!/^[0-9]+$/.test(delayNumber))
7675
validationFailed.delayNumber = true;
@@ -103,20 +102,9 @@ export const TimerDialog = ({ owner, timer }) => {
103102
if (Object.keys(validationFailed).length)
104103
return false;
105104

106-
setInProgress(true);
107-
108-
// Verify if the command exists
109-
const command_parts = command.split(" ");
110-
cockpit.spawn(["test", "-f", command_parts[0]], { err: "ignore" })
111-
.then(() => {
112-
create_timer({ name, description, command, delay, delayUnit, delayNumber, repeat, specificTime, repeatPatterns, owner })
113-
.then(Dialogs.close, exc => {
114-
setDialogError(exc.message);
115-
setInProgress(false);
116-
});
117-
})
118-
.catch(() => {
119-
setCommandNotFound(true);
105+
create_timer({ name, description, command, delay, delayUnit, delayNumber, repeat, specificTime, repeatPatterns, owner })
106+
.then(Dialogs.close, exc => {
107+
setDialogError(exc.message);
120108
setInProgress(false);
121109
});
122110

@@ -156,14 +144,15 @@ export const TimerDialog = ({ owner, timer }) => {
156144
onChange={(_event, value) => setDescription(value)} />
157145
<FormHelper fieldId="description" helperTextInvalid={submitted && validationFailed.description && _("This field cannot be empty")} />
158146
</FormGroup>
159-
<FormGroup label={_("Command")}
147+
<FormGroup label={_("Shell command")}
160148
fieldId="command">
161149
<TextInput id='command'
162150
value={command}
163151
validated={submitted && validationFailed.command ? "error" : "default"}
164-
onChange={(_event, str) => { setCommandNotFound(false); setCommand(str) }} />
152+
onChange={(_event, str) => { setCommand(str) }} />
165153
<FormHelper fieldId="command"
166-
helperTextInvalid={submitted && validationFailed.command && (commandNotFound ? _("Command not found") : _("This field cannot be empty"))} />
154+
helperText={_("This command will be executed by /bin/sh.")}
155+
helperTextInvalid={submitted && validationFailed.command && _("This field cannot be empty")} />
167156
</FormGroup>
168157
<FormGroup label={_("Trigger")} hasNoPaddingTop>
169158
<Flex>
@@ -379,7 +368,7 @@ export const TimerDialog = ({ owner, timer }) => {
379368
<Button variant='primary'
380369
id="timer-save-button"
381370
isLoading={inProgress}
382-
isDisabled={inProgress || commandNotFound}
371+
isDisabled={inProgress}
383372
onClick={onSubmit}>
384373
{_("Save")}
385374
</Button>

test/verify/check-system-services

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1485,7 +1485,7 @@ class TestTimers(testlib.MachineCase):
14851485
b.wait_visible("#timer-dialog")
14861486
b.set_input_text("#servicename", "yearly_timer")
14871487
b.set_input_text("#description", "Yearly timer")
1488-
b.set_input_text("#command", "/bin/sh -c '/bin/date >> /tmp/date'")
1488+
b.set_input_text("#command", "/bin/date >> /tmp/date")
14891489
b.select_from_dropdown("#drop-repeat", "yearly")
14901490
b.click("[data-index='0'] [aria-label='Add']")
14911491
b.set_input_text("[data-index='0'] .pf-v6-c-date-picker:nth-child(1) input", "2036-01-01")
@@ -1502,7 +1502,7 @@ class TestTimers(testlib.MachineCase):
15021502
b.wait_visible("#timer-dialog")
15031503
b.set_input_text("#servicename", "monthly_timer")
15041504
b.set_input_text("#description", "Monthly timer")
1505-
b.set_input_text("#command", "/bin/sh -c '/bin/date >> /tmp/date'")
1505+
b.set_input_text("#command", "/bin/date >> /tmp/date")
15061506
b.click("input[value=specific-time]")
15071507
b.select_from_dropdown("#drop-repeat", "monthly")
15081508
b.select_from_dropdown("[data-index='0'] .month-days select", "6")
@@ -1520,7 +1520,7 @@ class TestTimers(testlib.MachineCase):
15201520
b.wait_visible("#timer-dialog")
15211521
b.set_input_text("#servicename", "weekly_timer")
15221522
b.set_input_text("#description", "Weekly timer")
1523-
b.set_input_text("#command", "/bin/sh -c '/bin/date >> /tmp/date'")
1523+
b.set_input_text("#command", "/bin/date >> /tmp/date")
15241524
b.click("input[value=specific-time]")
15251525
b.select_from_dropdown("#drop-repeat", "weekly")
15261526
b.wait_visible("[data-index='0'] .week-days")
@@ -1541,7 +1541,7 @@ class TestTimers(testlib.MachineCase):
15411541
b.wait_visible("#timer-dialog")
15421542
b.set_input_text("#servicename", "daily_timer")
15431543
b.set_input_text("#description", "Daily timer")
1544-
b.set_input_text("#command", "/bin/sh -c '/bin/date >> /tmp/date'")
1544+
b.set_input_text("#command", "/bin/date >> /tmp/date")
15451545
b.click("input[value=specific-time]")
15461546
b.select_from_dropdown("#drop-repeat", "daily")
15471547
b.click("[data-index='0'] [aria-label='Add']")
@@ -1557,7 +1557,7 @@ class TestTimers(testlib.MachineCase):
15571557
b.wait_visible("#timer-dialog")
15581558
b.set_input_text("#servicename", "hourly_timer")
15591559
b.set_input_text("#description", "Hourly timer")
1560-
b.set_input_text("#command", "/bin/sh -c '/bin/date >> /tmp/date'")
1560+
b.set_input_text("#command", "/bin/date >> /tmp/date")
15611561
b.click("input[value=specific-time]")
15621562
b.select_from_dropdown("#drop-repeat", "hourly")
15631563
b.click("[data-index='0'] [aria-label='Add']")
@@ -1573,7 +1573,7 @@ class TestTimers(testlib.MachineCase):
15731573
b.wait_visible("#timer-dialog")
15741574
b.set_input_text("#servicename", "minutely_timer")
15751575
b.set_input_text("#description", "Minutely timer")
1576-
b.set_input_text("#command", "/bin/sh -c '/bin/date >> /tmp/date'")
1576+
b.set_input_text("#command", "/bin/date >> /tmp/date")
15771577
b.select_from_dropdown("#drop-repeat", "minutely")
15781578
b.click("[data-index='0'] [aria-label='Add']")
15791579
b.set_input_text("[data-index='0'] input", "05")
@@ -1589,7 +1589,7 @@ class TestTimers(testlib.MachineCase):
15891589
b.wait_visible("#timer-dialog")
15901590
b.set_input_text("#servicename", "no_repeat_timer")
15911591
b.set_input_text("#description", "No repeat timer")
1592-
b.set_input_text("#command", "/bin/sh -c '/bin/date >> /tmp/date'")
1592+
b.set_input_text("#command", "/bin/date >> /tmp/date")
15931593
b.click("input[value=specific-time]")
15941594
b.set_input_text(".create-timer-time-picker input", "23:59")
15951595
b.click("#timer-save-button")
@@ -1602,7 +1602,7 @@ class TestTimers(testlib.MachineCase):
16021602
b.wait_visible("#timer-dialog")
16031603
b.set_input_text("#servicename", "boot_timer")
16041604
b.set_input_text("#description", "Boot timer")
1605-
b.set_input_text("#command", "/bin/sh -c 'echo hello >> /tmp/hello'")
1605+
b.set_input_text("#command", "echo hello >> /tmp/hello")
16061606
b.click("input[value=system-boot]")
16071607
b.set_input_text(".delay-group input", "2")
16081608
b.click("#timer-save-button")
@@ -1650,7 +1650,7 @@ class TestTimers(testlib.MachineCase):
16501650
b.wait_visible("#timer-dialog")
16511651
b.set_input_text("#servicename", "testing timer")
16521652
m.execute("rm -f /tmp/date")
1653-
b.set_input_text("#command", "/bin/sh -c '/bin/date >> /tmp/date'")
1653+
b.set_input_text("#command", "/bin/date >> /tmp/date")
16541654
b.click("input[value=specific-time]")
16551655
b.set_input_text(".create-timer-time-picker input", "24:6s")
16561656
b.click("#timer-save-button")
@@ -1660,16 +1660,13 @@ class TestTimers(testlib.MachineCase):
16601660
b.wait_text("#description-helper", "This field cannot be empty")
16611661
b.wait_text(".pf-v6-c-date-picker__helper-text", "Invalid time format")
16621662

1663-
# checks for command not found
1663+
# correct input
16641664
b.set_input_text("#servicename", "testing")
16651665
b.set_input_text("#description", "desc")
1666-
b.set_input_text("#command", "this is not found")
16671666
b.set_input_text(".create-timer-time-picker input", "14:12")
1668-
b.click("#timer-save-button")
1669-
b.wait_text("#command-helper", "Command not found")
16701667

16711668
# shows creation errors
1672-
b.set_input_text("#command", "/bin/sh -c '/bin/date >> /tmp/date'")
1669+
b.set_input_text("#command", "/bin/date >> /tmp/date")
16731670
b.click("input[value=specific-time]")
16741671
b.set_input_text(".create-timer-time-picker input", "23:59")
16751672
m.execute("chattr +i /etc/systemd/system")
@@ -1814,7 +1811,7 @@ OnCalendar=weekly
18141811
b.click("#services-page .pf-v6-c-menu__list-item:contains('Edit') button")
18151812
self.assertEqual(b.val("#servicename"), "yearly_timer")
18161813
self.assertEqual(b.val("#description"), "Yearly timer")
1817-
self.assertEqual(b.val("#command"), "/bin/sh -c '/bin/date >> /tmp/date'")
1814+
self.assertEqual(b.val("#command"), "/bin/date >> /tmp/date")
18181815
for index in range(2):
18191816
date = b.val(f"[data-index='{index}'] .pf-v6-c-date-picker:nth-child(1) input")
18201817
time_ = b.val(f"[data-index='{index}'] .pf-v6-c-date-picker:nth-child(2) input")

0 commit comments

Comments
 (0)