Files
webmin/systemd/edit_unit.cgi
2026-06-14 23:37:30 +02:00

1137 lines
42 KiB
Perl
Executable File

#!/usr/local/bin/perl
# Show a form for creating, editing or viewing a systemd unit
use strict;
use warnings;
require './systemd-lib.pl'; ## no critic
our (%access, %config, %in, %text, $remote_user);
# Returns safe extra attributes for create-form placeholders.
sub placeholder_tags
{
my ($text, $tags) = @_;
my $rv = "placeholder=\"".quote_escape($text)."\"";
$rv .= " ".$tags if ($tags);
return $rv;
}
# Returns a create-form text box with a short example placeholder.
sub placeholder_textbox
{
my ($name, $value, $size, $placeholder) = @_;
return ui_textbox($name, $value, $size, undef, undef,
placeholder_tags($placeholder));
}
# Returns a create-form file box with a path-style placeholder.
sub placeholder_filebox
{
my ($name, $value, $size, $placeholder, $dironly) = @_;
return ui_filebox($name, $value, $size, undef, undef,
placeholder_tags($placeholder), $dironly);
}
# Returns a create-form text area with a command or directive example.
sub placeholder_textarea
{
my ($name, $value, $rows, $cols, $placeholder, $tags) = @_;
return ui_textarea($name, $value, $rows, $cols, undef, undef,
placeholder_tags($placeholder, $tags));
}
# Returns example home and runtime paths for user-scope placeholders.
sub user_scope_example_paths
{
my ($user) = @_;
my $uinfo = get_user_details($user);
my $home = $uinfo ? $uinfo->{'home'} : "/home/my-user";
my $runtime = $uinfo ? "/run/user/".$uinfo->{'uid'} : $home."/run";
return ($home, $runtime);
}
# Returns path-unit placeholders appropriate for system or user scope.
sub path_unit_placeholders
{
my ($user_scope, $user) = @_;
my %rv = (
'exists' => "/run/my-app.ready",
'existsglob' => "/var/spool/my-app/*.job",
'changed' => "/etc/my-app.conf",
'modified' => "/etc/my-app.d",
'directorynotempty' => "/var/spool/my-app",
);
if ($user_scope) {
my ($home, $runtime) = user_scope_example_paths($user);
%rv = (
'exists' => $runtime."/my-app.ready",
'existsglob' => $home."/spool/my-app/*.job",
'changed' => $home."/.config/my-app.conf",
'modified' => $home."/.config/my-app.d",
'directorynotempty' => $home."/spool/my-app",
);
}
return \%rv;
}
# Returns service-file placeholders appropriate for system or user scope.
sub service_unit_placeholders
{
my ($user_scope, $user) = @_;
my %rv = (
'pidfile' => "/run/my-app.pid",
'envfile' => "-/etc/default/my-app",
'workdir' => "/srv/my-app",
'readwritepaths' => "/var/lib/my-app",
'startpre' => "/usr/bin/install -d /run/my-app",
'stoppost' => "/usr/bin/rm -f /run/my-app.pid",
);
if ($user_scope) {
my ($home, $runtime) = user_scope_example_paths($user);
%rv = (
'pidfile' => $runtime."/my-app.pid",
'envfile' => "-".$home."/.config/my-app/env",
'workdir' => $home."/my-app",
'readwritepaths' => $home."/my-app",
'startpre' => "/usr/bin/install -d ".$runtime."/my-app",
'stoppost' => "/usr/bin/rm -f ".$runtime."/my-app.pid",
);
}
return \%rv;
}
# Returns socket placeholders appropriate for system or user scope.
sub socket_unit_placeholders
{
my ($user_scope, $user) = @_;
my %rv = ( 'listenfifo' => "/run/my-app.fifo" );
if ($user_scope) {
my (undef, $runtime) = user_scope_example_paths($user);
$rv{'listenfifo'} = $runtime."/my-app.fifo";
}
return \%rv;
}
ReadParse();
# Work out whether this page is creating or editing a user-scoped unit.
# User-scope units live in the selected Unix user's systemd manager.
my $unituser = clean_unit_value($in{'unituser'} || $in{'user'});
my $edit_user_scope = !$in{'new'} && $in{'scope'} eq 'user' ? 1 : 0;
my $create_default_user_scope = $in{'new'} && !defined($in{'scope'}) &&
$config{'default_create_scope'} eq 'user' ? 1 : 0;
my $create_user_scope = $in{'new'} &&
(($in{'scope'} || "") eq 'user' || $create_default_user_scope) ? 1 : 0;
my $edit_dropin = !$in{'new'} && $in{'dropin'} ? 1 : 0;
my $dropin_file = $edit_dropin ? clean_unit_value($in{'dropfile'}) : "";
my $dropin_info;
if ($in{'new'} && $create_user_scope && !$unituser) {
$unituser = systemd_acl_default_user(\%access) || "";
if (!$unituser) {
my $ruinfo = get_user_details($remote_user);
$unituser = $ruinfo->{'user'}
if ($ruinfo && $ruinfo->{'uid'} != 0);
}
}
if (!$in{'new'}) {
valid_unit_name($in{'name'}) ||
error($text{'systemd_ename'});
}
my ($u, $conf);
my (@units, @unittypes, @types, @killmodes, @restarts, @protects);
my (%creatable_types);
my $default_unittype = 'service';
my $unit_file_editable = 0;
my $can_save_unit = 0;
my $remote_uinfo = get_user_details($remote_user);
# New units start with an empty record. Existing units are looked up from the
# selected system or user scope so edits cannot cross scopes accidentally.
if ($in{'new'}) {
systemd_can_create(\%access, $create_user_scope,
$create_user_scope ? $unituser : undef) ||
systemd_acl_error($create_user_scope ?
'pcreate_user' : 'pcreate');
# The create form renders structured fields instead of raw unit contents.
ui_print_header(undef, $text{'systemd_title1'}, "");
$u = { };
}
else {
# Editing keeps the unit in its current system or user manager.
if ($edit_user_scope) {
# The owner must be a real Unix user before we inspect their units.
get_user_details($unituser) ||
error($text{'systemd_euser'});
systemd_can_view_scope(\%access, 1, $unituser) ||
systemd_acl_error('pview_user');
@units = list_user_units($unituser);
}
else {
# System-scope edits use the system unit list.
systemd_can_view_scope(\%access, 0) ||
systemd_acl_error('pview');
@units = list_units();
}
# Reject stale edit links after units have been deleted or renamed.
($u) = grep { $_->{'name'} eq $in{'name'} } @units;
$u || error($text{'systemd_egone'});
if ($edit_dropin && $dropin_file) {
$dropin_info = $edit_user_scope ?
user_dropin_config_file_info($unituser, $dropin_file) :
system_dropin_config_file_info($dropin_file);
$dropin_info && $dropin_info->{'unit'} eq $in{'name'} ||
error($text{'systemd_edropinfile'});
$dropin_file = $dropin_info->{'file'};
}
$unit_file_editable = unit_file_editable($u);
$can_save_unit = $edit_dropin ?
systemd_can_dropin(\%access, $edit_user_scope, $unituser) :
systemd_can_edit(\%access, $edit_user_scope, $unituser);
# Runtime-managed units are inspect-only, so title them as views.
my $title_key = $unit_file_editable && $can_save_unit ?
($edit_user_scope ? 'systemd_title2_user' : 'systemd_title2') :
($edit_user_scope ? 'systemd_title2_view_user' :
'systemd_title2_view');
ui_print_header(undef, $text{$title_key}, "");
}
# The save script uses hidden scope fields to pick the correct control plane
# for later actions, including status/log redirects.
print ui_form_start("save_unit.cgi", "post");
print ui_hidden("new", $in{'new'});
print ui_hidden("scope", "user") if ($edit_user_scope);
print ui_hidden("unituser", $unituser) if ($edit_user_scope);
print ui_hidden("name", $in{'name'}) if (!$in{'new'});
print ui_hidden("dropin", 1) if ($edit_dropin);
print ui_hidden("dropfile", $dropin_file) if ($edit_dropin && $dropin_file);
if ($in{'new'}) {
# The first table contains the fields that almost every new unit needs.
print ui_table_start($text{'systemd_header'}, undef, 2);
# Unit type and name. The suffix is displayed separately, but the save
# script appends or validates it before writing the unit file.
my @creatable_unit_types = get_creatable_unit_types($create_user_scope);
@unittypes = map { [ $_, $text{'systemd_type_'.$_} || $_ ] }
@creatable_unit_types;
%creatable_types = map { $_, 1 } @creatable_unit_types;
$default_unittype = $creatable_types{$in{'unittype'}} ?
$in{'unittype'} : "service";
my $type_help = $create_user_scope ?
"systemd_type_user" : "systemd_type";
print ui_table_row(hlink($text{'systemd_type'}, $type_help),
ui_select("unittype", $default_unittype, \@unittypes,
1, 0, 0, 0));
print ui_table_hr();
print ui_table_row(hlink($text{'systemd_name'}, "systemd_name"),
placeholder_textbox("name", undef, 30, "my-app").
ui_tag('tt', ".$default_unittype",
{ 'id' => 'systemd_name_suffix' }));
# Every new unit needs a Description= line.
print ui_table_row(hlink($text{'systemd_desc'}, "systemd_desc"),
placeholder_textbox("desc", undef, 60,
"My app service"));
# Existing mount units can be paired with a new automount, so the user
# does not need to derive the automount name by hand.
my @mount_units;
if ($create_user_scope && get_user_details($unituser)) {
@mount_units = grep { $_->{'name'} =~ /\.mount$/ }
list_user_units($unituser);
}
elsif (!$create_user_scope) {
@mount_units = grep { $_->{'name'} =~ /\.mount$/ }
list_units();
}
my @automount_mounts = ( [ '', $text{'systemd_automountmount_none'} ] );
foreach my $mu (sort { $a->{'name'} cmp $b->{'name'} } @mount_units) {
my $where = mount_unit_where(
$mu, $create_user_scope ? $unituser : undef);
my $label = $mu->{'name'}.
($where ? " (".$where.")" : "");
push(@automount_mounts, [ $mu->{'name'}, html_escape($label) ]);
}
# Service units use command fields rather than raw [Service] body text.
print ui_table_row(hlink($text{'systemd_start'}, "systemd_start"),
placeholder_textarea("atstart", undef, 5, 80,
"/usr/bin/my-app --foreground"),
1, undef, [ "data-systemd-service='1'" ]);
# The stop command is optional; the save page can generate a default.
print ui_table_row(hlink($text{'systemd_stop'}, "systemd_stop"),
placeholder_textarea("atstop", undef, 5, 80,
"/bin/kill -TERM \$MAINPID"),
1, undef, [ "data-systemd-service='1'" ]);
# Timer units can be created from common activation fields. More unusual
# timer directives remain available in the advanced body editor below.
my @timer_row = ( "data-systemd-timer='1' style='display:none'" );
print ui_table_row(hlink($text{'systemd_timeroncalendar'},
"systemd_timeroncalendar"),
placeholder_textbox("timer_oncalendar", undef, 40,
"daily"),
1, undef, \@timer_row);
print ui_table_row(hlink($text{'systemd_timeronbootsec'},
"systemd_timeronbootsec"),
placeholder_textbox("timer_onbootsec", undef, 10,
"5min"),
1, undef, \@timer_row);
print ui_table_row(hlink($text{'systemd_timeronunitactivesec'},
"systemd_timeronunitactivesec"),
placeholder_textbox("timer_onunitactivesec", undef,
10, "1h"),
1, undef, \@timer_row);
print ui_table_row(hlink($text{'systemd_timerpersistent'},
"systemd_timerpersistent"),
ui_yesno_radio("timer_persistent", 0),
1, undef, \@timer_row);
print ui_table_row(hlink($text{'systemd_timerrandomizeddelaysec'},
"systemd_timerrandomizeddelaysec"),
placeholder_textbox("timer_randomizeddelaysec", undef,
10, "10min"),
1, undef, \@timer_row);
print ui_table_row(hlink($text{'systemd_timeraccuracysec'},
"systemd_timeraccuracysec"),
placeholder_textbox("timer_accuracysec", undef, 10,
"1min"),
1, undef, \@timer_row);
print ui_table_row(hlink($text{'systemd_timerunit'},
"systemd_timerunit"),
placeholder_textbox("timer_unit", undef, 40,
"my-job.service"),
1, undef, \@timer_row);
# Socket units expose the usual listeners and ownership controls.
my @socket_row = ( "data-systemd-socket='1' style='display:none'" );
my $socket_placeholders =
socket_unit_placeholders($create_user_scope, $unituser);
print ui_table_row(hlink($text{'systemd_socketlistenstream'},
"systemd_socketlistenstream"),
placeholder_textbox("socket_listenstream", undef, 40,
"127.0.0.1:8080"),
1, undef, \@socket_row);
print ui_table_row(hlink($text{'systemd_socketlistendatagram'},
"systemd_socketlistendatagram"),
placeholder_textbox("socket_listendatagram", undef,
40, "10514"),
1, undef, \@socket_row);
print ui_table_row(hlink($text{'systemd_socketlistenfifo'},
"systemd_socketlistenfifo"),
placeholder_filebox("socket_listenfifo", undef, 50,
$socket_placeholders->{'listenfifo'}, 1),
1, undef, \@socket_row);
print ui_table_row(hlink($text{'systemd_socketaccept'},
"systemd_socketaccept"),
ui_yesno_radio("socket_accept", 0),
1, undef, \@socket_row);
print ui_table_row(hlink($text{'systemd_socketuser'},
"systemd_socketuser"),
placeholder_textbox("socket_user", undef, 20,
"appuser")." ".
user_chooser_button("socket_user"),
1, undef, [ "id='systemd_socket_user_row' ".
"data-systemd-socket='1' style='display:none'" ]);
print ui_table_row(hlink($text{'systemd_socketgroup'},
"systemd_socketgroup"),
placeholder_textbox("socket_group", undef, 20,
"appgroup")." ".
group_chooser_button("socket_group"),
1, undef, [ "id='systemd_socket_group_row' ".
"data-systemd-socket='1' style='display:none'" ]);
print ui_table_row(hlink($text{'systemd_socketmode'},
"systemd_socketmode"),
placeholder_textbox("socket_mode", undef, 10, "0660"),
1, undef, \@socket_row);
print ui_table_row(hlink($text{'systemd_socketservice'},
"systemd_socketservice"),
placeholder_textbox("socket_service", undef, 40,
"my-app.service"),
1, undef, \@socket_row);
# Path units watch files or directories and activate another unit.
my @path_row = ( "data-systemd-path='1' style='display:none'" );
my $path_placeholders =
path_unit_placeholders($create_user_scope, $unituser);
print ui_table_row(hlink($text{'systemd_pathexists'},
"systemd_pathexists"),
placeholder_filebox("path_exists", undef, 50,
$path_placeholders->{'exists'}, 1),
1, undef, \@path_row);
print ui_table_row(hlink($text{'systemd_pathexistsglob'},
"systemd_pathexistsglob"),
placeholder_textbox("path_existsglob", undef, 50,
$path_placeholders->{'existsglob'}),
1, undef, \@path_row);
print ui_table_row(hlink($text{'systemd_pathchanged'},
"systemd_pathchanged"),
placeholder_filebox("path_changed", undef, 50,
$path_placeholders->{'changed'}, 1),
1, undef, \@path_row);
print ui_table_row(hlink($text{'systemd_pathmodified'},
"systemd_pathmodified"),
placeholder_filebox("path_modified", undef, 50,
$path_placeholders->{'modified'}, 1),
1, undef, \@path_row);
print ui_table_row(hlink($text{'systemd_pathdirectorynotempty'},
"systemd_pathdirectorynotempty"),
placeholder_filebox("path_directorynotempty", undef,
50,
$path_placeholders->{'directorynotempty'}, 1),
1, undef, \@path_row);
print ui_table_row(hlink($text{'systemd_pathmakedirectory'},
"systemd_pathmakedirectory"),
ui_yesno_radio("path_makedirectory", 0),
1, undef, \@path_row);
print ui_table_row(hlink($text{'systemd_pathunit'},
"systemd_pathunit"),
placeholder_textbox("path_unit", undef, 40,
"my-app.service"),
1, undef, \@path_row);
# Mount units have a small, stable set of required fields. The unit name
# can be derived from Where= on save.
my @mount_row = ( "data-systemd-mount='1' style='display:none'" );
print ui_table_row(hlink($text{'systemd_mountwhat'}, "systemd_mountwhat"),
placeholder_textbox("mount_what", undef, 60,
"/dev/disk/by-uuid/abcd-1234"),
1, undef, \@mount_row);
print ui_table_row(hlink($text{'systemd_mountwhere'}, "systemd_mountwhere"),
placeholder_filebox("mount_where", undef, 50,
"/mnt/data", 1),
1, undef, \@mount_row);
print ui_table_row(hlink($text{'systemd_mounttype'}, "systemd_mounttype"),
placeholder_textbox("mount_type", undef, 20, "ext4"),
1, undef, \@mount_row);
print ui_table_row(hlink($text{'systemd_mountoptions'}, "systemd_mountoptions"),
placeholder_textbox("mount_options", undef, 60,
"defaults,noatime"),
1, undef, \@mount_row);
# Automount units activate a matching mount unit by path. Selecting an
# existing mount lets save_unit.cgi derive the automount name safely.
my @automount_row = (
"data-systemd-automount='1' style='display:none'" );
print ui_table_row(hlink($text{'systemd_automountmount'},
"systemd_automountmount"),
ui_select("automount_mount", undef,
\@automount_mounts),
1, undef, \@automount_row);
print ui_table_row(hlink($text{'systemd_automountwhere'},
"systemd_automountwhere"),
placeholder_filebox("automount_where", undef, 50,
"/mnt/data", 1),
1, undef, \@automount_row);
print ui_table_row(hlink($text{'systemd_automountidle'},
"systemd_automountidle"),
placeholder_textbox("automount_idle", undef, 10,
"5min"),
1, undef, \@automount_row);
print ui_table_row(hlink($text{'systemd_automountmode'},
"systemd_automountmode"),
placeholder_textbox("automount_mode", undef, 10,
"0755"),
1, undef, \@automount_row);
# Swap and slice units have a few common directives worth exposing.
my @swap_row = ( "data-systemd-swap='1' style='display:none'" );
print ui_table_row(hlink($text{'systemd_swapwhat'}, "systemd_swapwhat"),
placeholder_filebox("swap_what", undef, 50,
"/swapfile", 1),
1, undef, \@swap_row);
print ui_table_row(hlink($text{'systemd_swappriority'},
"systemd_swappriority"),
placeholder_textbox("swap_priority", undef, 10, "10"),
1, undef, \@swap_row);
print ui_table_row(hlink($text{'systemd_swapoptions'},
"systemd_swapoptions"),
placeholder_textbox("swap_options", undef, 60,
"discard"),
1, undef, \@swap_row);
print ui_table_row(hlink($text{'systemd_swaptimeoutsec'},
"systemd_swaptimeoutsec"),
placeholder_textbox("swap_timeoutsec", undef, 10,
"30s"),
1, undef, \@swap_row);
my @slice_row = ( "data-systemd-slice='1' style='display:none'" );
print ui_table_row(hlink($text{'systemd_slicecpuweight'},
"systemd_slicecpuweight"),
placeholder_textbox("slice_cpuweight", undef, 10,
"200"),
1, undef, \@slice_row);
print ui_table_row(hlink($text{'systemd_slicememorymax'},
"systemd_slicememorymax"),
placeholder_textbox("slice_memorymax", undef, 10,
"512M"),
1, undef, \@slice_row);
print ui_table_row(hlink($text{'systemd_slicetasksmax'},
"systemd_slicetasksmax"),
placeholder_textbox("slice_tasksmax", undef, 10,
"500"),
1, undef, \@slice_row);
print ui_table_row(hlink($text{'systemd_sliceioweight'},
"systemd_sliceioweight"),
placeholder_textbox("slice_ioweight", undef, 10,
"200"),
1, undef, \@slice_row);
# Startup state is applied after the unit is created.
if (systemd_can_boot(\%access, $create_user_scope,
$create_user_scope ? $unituser : undef)) {
print ui_table_row(hlink($text{'systemd_boot'}, "systemd_boot"),
ui_yesno_radio("boot", 1));
}
# Pick a safe default owner for new user units when possible.
my $default_unituser = $unituser;
my $acl_default_unituser;
if (!$default_unituser) {
$acl_default_unituser =
systemd_acl_default_user(\%access) || "";
$default_unituser = $acl_default_unituser;
if (!$default_unituser) {
$default_unituser = $remote_uinfo->{'user'}
if ($remote_uinfo &&
$remote_uinfo->{'uid'} != 0);
}
}
my $force_user_scope_create = $create_user_scope &&
!systemd_can_create(\%access, 0) &&
(($remote_uinfo && $remote_uinfo->{'uid'} != 0) ||
$acl_default_unituser) ? 1 : 0;
my $force_user_scope_owner = $force_user_scope_create &&
$default_unituser ? 1 : 0;
# User units live in the selected user's home and run under that user's
# systemd manager, so the service-level User=/Group= rows are hidden by JS.
if ($force_user_scope_create) {
print ui_hidden("userservice", 1);
}
else {
print ui_table_row(hlink($text{'systemd_userservice'},
"systemd_userservice"),
ui_radio("userservice",
$create_user_scope ? 1 : 0,
[ [ 1, $text{'yes'} ],
[ 0, $text{'no'} ] ]),
1, undef,
[ "id='systemd_userservice_row'" ]);
print ui_table_hr();
}
if ($force_user_scope_owner) {
print ui_hidden("unituser", $default_unituser);
}
else {
print ui_table_row(hlink($text{'systemd_unituser'},
"systemd_unituser"),
placeholder_textbox("unituser",
$default_unituser, 20,
"appuser")." ".
user_chooser_button("unituser"),
1, undef,
[ "id='systemd_unituser_row'".
($create_user_scope ? "" :
" style='display:none'") ]);
}
if (systemd_acl_bool(\%access, 'linger')) {
my $linger_text = $create_user_scope ?
$text{'systemd_linger_user'} : $text{'systemd_linger'};
my $linger_help = $create_user_scope ?
"systemd_linger_user" : "systemd_linger";
print ui_table_row(hlink($linger_text, $linger_help),
ui_yesno_radio("linger",
$config{'default_linger'} ? 1 : 0),
1, undef, [ "id='systemd_linger_row'".
($create_user_scope ? "" : " style='display:none'") ]);
}
print ui_table_end();
# Less common create-time settings are collapsed by default.
print ui_hidden_table_start($text{'systemd_advanced'}, undef, 2,
"advanced", 0);
# Unit relationships are shared by all creatable unit types and are written
# into the [Unit] section.
print ui_table_row(hlink($text{'systemd_before'}, "systemd_before"),
placeholder_textbox("before", undef, 60,
"network.target"));
print ui_table_row(hlink($text{'systemd_after'}, "systemd_after"),
placeholder_textbox("after", undef, 60,
"network-online.target"));
print ui_table_row(hlink($text{'systemd_wants'}, "systemd_wants"),
placeholder_textbox("wants", undef, 60,
"network-online.target"));
print ui_table_row(hlink($text{'systemd_requires'}, "systemd_requires"),
placeholder_textbox("requires", undef, 60,
"postgresql.service"));
print ui_table_row(hlink($text{'systemd_conflicts'}, "systemd_conflicts"),
placeholder_textbox("conflicts", undef, 60,
"old-app.service"));
print ui_table_row(hlink($text{'systemd_onfailure'}, "systemd_onfailure"),
placeholder_textbox("onfailure", undef, 60,
"notify@%n.service"));
print ui_table_row(hlink($text{'systemd_onsuccess'}, "systemd_onsuccess"),
placeholder_textbox("onsuccess", undef, 60,
"report.service"));
# Service options become irrelevant for all non-service unit types; each row
# is marked so the JS type switch can hide it.
my @service_row = ( "data-systemd-service='1'" );
my $service_placeholders =
service_unit_placeholders($create_user_scope, $unituser);
@types = ( [ '', $text{'default'} ], "simple", "exec", "forking",
"oneshot", "dbus", "notify", "idle" );
print ui_table_row(hlink($text{'systemd_servicetype'}, "systemd_servicetype"),
ui_select("type", undef, \@types),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_remain'}, "systemd_remain"),
ui_yesno_radio("remain", 0),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_pidfile'}, "systemd_pidfile"),
placeholder_filebox("pidfile", undef, 50,
$service_placeholders->{'pidfile'}),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_env'}, "systemd_env"),
placeholder_textbox("env", undef, 60,
"NODE_ENV=production PORT=8080"),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_envfile'}, "systemd_envfile"),
placeholder_filebox("envfile", undef, 50,
$service_placeholders->{'envfile'}),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_user'}, "systemd_user"),
placeholder_textbox("user", undef, 20, "appuser")." ".
user_chooser_button("user"),
1, undef, [ "id='systemd_runas_user_row' ".
"data-systemd-service='1'".
($create_user_scope ? " style='display:none'" : "") ]);
print ui_table_row(hlink($text{'systemd_group'}, "systemd_group"),
placeholder_textbox("group", undef, 20, "appgroup")." ".
group_chooser_button("group"),
1, undef, [ "id='systemd_runas_group_row' ".
"data-systemd-service='1'".
($create_user_scope ? " style='display:none'" : "") ]);
@killmodes = ( [ '', $text{'default'} ], "control-group",
"process", "mixed", "none" );
print ui_table_row(hlink($text{'systemd_killmode'}, "systemd_killmode"),
ui_select("killmode", undef, \@killmodes),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_workdir'}, "systemd_workdir"),
placeholder_filebox("workdir", undef, 50,
$service_placeholders->{'workdir'}, 1),
1, undef, \@service_row);
@restarts = ( [ '', $text{'default'} ], "no", "on-success",
"on-failure", "on-abnormal", "on-watchdog",
"on-abort", "always" );
print ui_table_row(hlink($text{'systemd_restart'}, "systemd_restart"),
ui_select("restart_policy", undef, \@restarts),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_restartsec'}, "systemd_restartsec"),
placeholder_textbox("restartsec", undef, 10, "5s"),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_watchdogsec'}, "systemd_watchdogsec"),
placeholder_textbox("watchdogsec", undef, 10, "30s"),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_timeout'}, "systemd_timeout"),
placeholder_textbox("timeoutstartsec", undef, 10,
"30s"),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_timeoutstop'}, "systemd_timeoutstop"),
placeholder_textbox("timeoutstopsec", undef, 10,
"30s"),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_limitnofile'}, "systemd_limitnofile"),
placeholder_textbox("limitnofile", undef, 10,
"65535"),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_logstd'}, "systemd_logstd"),
placeholder_textbox("logstd", undef, 50,
"journal"),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_logerr'}, "systemd_logerr"),
placeholder_textbox("logerr", undef, 50,
"journal"),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_syslogid'}, "systemd_syslogid"),
placeholder_textbox("syslogid", undef, 30, "my-app"),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_nonewprivs'}, "systemd_nonewprivs"),
ui_yesno_radio("nonewprivs", 0),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_privatetmp'}, "systemd_privatetmp"),
ui_yesno_radio("privatetmp", 0),
1, undef, \@service_row);
@protects = ( [ '', $text{'default'} ], "true", "full", "strict" );
print ui_table_row(hlink($text{'systemd_protectsystem'}, "systemd_protectsystem"),
ui_select("protectsystem", undef, \@protects),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_readwritepaths'}, "systemd_readwritepaths"),
placeholder_textbox("readwritepaths", undef, 60,
$service_placeholders->{'readwritepaths'}),
1, undef, \@service_row);
# Install options stay visible for all types. JS changes the default target
# when switching between system/user units or between unit types.
my $default_wantedby =
get_default_install_target($default_unittype,
$create_user_scope);
print ui_table_row(hlink($text{'systemd_wantedby'}, "systemd_wantedby"),
placeholder_textbox("wantedby", $default_wantedby,
60, "multi-user.target"));
# Extra non-service directives supplement the guided fields above. Only
# directive lines belong here; the renderer adds the correct section header.
print ui_table_row(hlink($text{'systemd_unitconf'}, "systemd_unitconf"),
placeholder_textarea("unitconf", undef, 8, 80,
"RuntimeMaxSec=1h", "spellcheck='false'"),
1, undef, [ "data-systemd-extra='1' ".
"style='display:none'" ]);
# Extra command hooks are service-only and are kept near the end because
# they are less commonly needed than the scalar service settings above.
print ui_table_row(hlink($text{'systemd_startpre'}, "systemd_startpre"),
placeholder_textarea("startpre", undef, 3, 80,
$service_placeholders->{'startpre'}),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_startpost'}, "systemd_startpost"),
placeholder_textarea("startpost", undef, 3, 80,
"/usr/bin/logger my-app started"),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_stoppost'}, "systemd_stoppost"),
placeholder_textarea("stoppost", undef, 3, 80,
$service_placeholders->{'stoppost'}),
1, undef, \@service_row);
print ui_table_row(hlink($text{'systemd_reload'}, "systemd_reload"),
placeholder_textarea("reload", undef, 3, 80,
"/bin/kill -HUP \$MAINPID"),
1, undef, \@service_row);
print ui_hidden_table_end("advanced");
my $systemd_js = <<'EOF';
(function() {
'use strict';
// Unit suffixes shown next to the editable base name.
const systemdSuffixes = {
service: '.service',
timer: '.timer',
socket: '.socket',
path: '.path',
target: '.target',
mount: '.mount',
automount: '.automount',
swap: '.swap',
slice: '.slice'
};
// Type-aware examples keep the create form from sounding service-only.
const systemdNamePlaceholders = {
service: 'my-app',
timer: 'nightly-backup',
socket: 'my-app',
path: 'config-watch',
target: 'app-stack',
mount: 'mnt-data',
automount: 'mnt-data',
swap: 'swapfile',
slice: 'app-workload'
};
const systemdDescPlaceholders = {
service: 'My app service',
timer: 'Nightly backup timer',
socket: 'My app socket',
path: 'Watch app config',
target: 'App stack target',
mount: 'Mount /mnt/data',
automount: 'Automount /mnt/data',
swap: 'Swap file',
slice: 'App resource slice'
};
const systemdExtraPlaceholders = {
timer: 'OnUnitInactiveSec=2min\nWakeSystem=no\nRemainAfterElapse=yes',
socket: 'Backlog=32\nKeepAlive=yes\nNoDelay=yes\nFileDescriptorName=my-app',
path: 'TriggerLimitIntervalSec=30s\nTriggerLimitBurst=10',
mount: 'TimeoutSec=30s\nLazyUnmount=yes',
automount: 'DirectoryMode=0755',
swap: 'TimeoutSec=30s',
slice: 'CPUQuota=50%\nMemoryHigh=256M'
};
// Default install targets mirror systemd's usual system and user unit targets.
const systemdInstallTargets = {
system: {
service: 'multi-user.target',
timer: 'timers.target',
socket: 'sockets.target',
path: 'paths.target',
target: 'multi-user.target',
mount: 'local-fs.target',
automount: 'local-fs.target',
swap: 'swap.target',
slice: 'slices.target'
},
user: {
service: 'default.target',
timer: 'timers.target',
socket: 'sockets.target',
path: 'paths.target',
target: 'default.target',
mount: 'default.target',
automount: 'default.target',
swap: 'default.target',
slice: 'slices.target'
}
};
// Returns the currently selected type, falling back to the service form.
function currentUnitType()
{
const field = document.querySelector('select[name="unittype"]');
return field && field.value ? field.value : 'service';
}
// Updates generic placeholders to match the currently selected unit type.
function updateTypePlaceholders()
{
const type = currentUnitType();
const nameField = document.querySelector('input[name="name"]');
const descField = document.querySelector('input[name="desc"]');
if (nameField && systemdNamePlaceholders[type]) {
nameField.setAttribute('placeholder', systemdNamePlaceholders[type]);
}
if (descField && systemdDescPlaceholders[type]) {
descField.setAttribute('placeholder', systemdDescPlaceholders[type]);
}
const extraField = document.querySelector('textarea[name="unitconf"]');
if (extraField) {
extraField.setAttribute('placeholder',
systemdExtraPlaceholders[type] || '');
}
}
// Detects defaults we own, so a custom WantedBy value is not overwritten.
function knownInstallTarget(value)
{
for (const scope in systemdInstallTargets) {
for (const type in systemdInstallTargets[scope]) {
if (systemdInstallTargets[scope][type] == value) {
return true;
}
}
}
return false;
}
// Refreshes WantedBy only when it is blank or still one of our defaults.
function updateInstallTarget(userMode)
{
const field = document.querySelector('[name="wantedby"]');
if (!field) {
return;
}
const scope = userMode ? 'user' : 'system';
const target = systemdInstallTargets[scope][currentUnitType()];
if (target && (!field.value || knownInstallTarget(field.value))) {
field.value = target;
}
}
// Shows user-manager fields and hides service User=/Group= in user mode.
function userModeChange()
{
let checked = document.querySelector('input[name="userservice"]:checked');
const hidden = document.querySelector('input[name="userservice"][type="hidden"]');
const f = checked ? checked.form : null;
const userservice = f ? f.elements['userservice'] :
document.querySelectorAll('input[name="userservice"]');
if (!checked && userservice) {
for (let i = 0; i < userservice.length; i++) {
if (userservice[i].checked) {
checked = userservice[i];
break;
}
}
}
const enabled = checked ? checked.value == '1' :
hidden ? hidden.value == '1' : false;
const service = currentUnitType() == 'service';
const socket = currentUnitType() == 'socket';
const showrow = function(id, show) {
const row = document.getElementById(id);
if (row) {
row.style.display = show ? '' : 'none';
}
};
showrow('systemd_unituser_row', enabled);
showrow('systemd_linger_row', enabled);
const userserviceHr =
document.querySelector('#systemd_userservice_row + tr');
if (userserviceHr) {
userserviceHr.style.display = enabled ? '' : 'none';
}
showrow('systemd_runas_user_row', !enabled && service);
showrow('systemd_runas_group_row', !enabled && service);
showrow('systemd_socket_user_row', !enabled && socket);
showrow('systemd_socket_group_row', !enabled && socket);
updateInstallTarget(enabled);
}
// Switches between service-specific rows and each unit type's guided fields.
function unitTypeChange()
{
const type = currentUnitType();
const service = type == 'service';
const extra = !service && type != 'target';
const mount = type == 'mount';
const automount = type == 'automount';
const typedRowSets = {
timer: document.querySelectorAll('[data-systemd-timer]'),
socket: document.querySelectorAll('[data-systemd-socket]'),
path: document.querySelectorAll('[data-systemd-path]'),
mount: document.querySelectorAll('[data-systemd-mount]'),
automount: document.querySelectorAll('[data-systemd-automount]'),
swap: document.querySelectorAll('[data-systemd-swap]'),
slice: document.querySelectorAll('[data-systemd-slice]')
};
const suffix = document.getElementById('systemd_name_suffix');
if (suffix) {
suffix.textContent = systemdSuffixes[type] || '';
}
updateTypePlaceholders();
const serviceRows = document.querySelectorAll('[data-systemd-service]');
for (let i = 0; i < serviceRows.length; i++) {
serviceRows[i].style.display = service ? '' : 'none';
}
for (const rowType in typedRowSets) {
const rows = typedRowSets[rowType];
for (let i = 0; i < rows.length; i++) {
rows[i].style.display = type == rowType ? '' : 'none';
}
}
const extraRows = document.querySelectorAll('[data-systemd-extra]');
for (let i = 0; i < extraRows.length; i++) {
extraRows[i].style.display = extra ? '' : 'none';
}
userModeChange();
}
// Authentic and Gray themes can render rows at different times, so initialize
// after DOM readiness and also bind explicit change handlers.
function initializeSystemdUnitForm()
{
const systemdUserServiceInputs =
document.querySelectorAll('input[name="userservice"]');
for (let i = 0; i < systemdUserServiceInputs.length; i++) {
systemdUserServiceInputs[i].addEventListener('change',
userModeChange);
}
const systemdUnitTypeInput = document.querySelector('select[name="unittype"]');
if (systemdUnitTypeInput) {
systemdUnitTypeInput.addEventListener('change',
unitTypeChange);
}
unitTypeChange();
}
if (document.readyState == 'loading') {
document.addEventListener('DOMContentLoaded', initializeSystemdUnitForm);
}
else {
initializeSystemdUnitForm();
}
})();
EOF
print ui_tag('script', $systemd_js,
{ 'type' => 'text/javascript' });
}
else {
# Existing units are edited as raw files to preserve unknown directives.
print ui_table_start($text{'systemd_header'}, undef, 2);
# Unit names are identifiers and cannot be renamed from the edit page.
print ui_table_row(hlink($text{'systemd_name'}, "systemd_name"),
ui_tag('tt', html_escape($in{'name'})));
# Show the resolved file path before the editable unit contents.
my $edit_file = $u->{'file'};
if ($edit_dropin) {
$edit_file = $dropin_file ||
($edit_user_scope ?
user_dropin_file($unituser, $in{'name'}) :
system_dropin_file($in{'name'}));
$edit_file || error($text{'systemd_edropinfile'});
}
print ui_table_row(hlink($text{'systemd_file'}, "systemd_file"),
ui_tag('tt', html_escape($edit_file)));
# User files are read through privilege-dropping helpers so a path in the
# home tree cannot make root follow user-controlled symlinks.
if ($edit_dropin) {
$conf = $dropin_file && $edit_user_scope ?
read_user_dropin_config_file($unituser, $dropin_file) :
$dropin_file ?
read_system_dropin_config_file($dropin_file) :
$edit_user_scope ?
read_user_dropin_file($unituser, $in{'name'}) :
read_system_dropin_file($in{'name'});
defined($conf) || error($text{'systemd_edropinfile'});
}
else {
$conf = $edit_user_scope ?
read_user_unit_file($unituser, $u->{'file'}) :
read_file_contents($u->{'file'});
defined($conf) || error($text{'systemd_euserunitfile'});
}
print ui_table_row(hlink($text{'systemd_conf'}, "systemd_conf"),
ui_textarea("data", $conf, 20, 80, undef,
undef, $unit_file_editable &&
$can_save_unit ? undef :
"readonly='readonly'"));
if ($edit_user_scope) {
# The owner is fixed for an existing user unit.
print ui_table_row(hlink($text{'systemd_unituser'}, "systemd_unituser"),
ui_tag('tt', html_escape($unituser)));
}
# Show systemd's own state model before editable policy toggles.
print ui_table_row(hlink($text{'systemd_runtime_state'},
"systemd_runtime_state"),
edit_runtime_state($u->{'runtime'}, $u->{'substate'}));
if (defined($u->{'pid'}) && $u->{'pid'} =~ /^\d+$/ && $u->{'pid'} > 0) {
print ui_table_row(hlink($text{'systemd_main_pid'},
"systemd_main_pid"),
ui_tag('tt', html_escape($u->{'pid'})));
}
print ui_table_row(hlink($text{'systemd_unit_state'},
"systemd_unit_state"),
edit_state_value($u->{'unitstate'}));
# Only file-backed installable units can have their startup state changed.
if (boot_state_changeable($u->{'unitstate'}, $u->{'name'}) &&
systemd_can_boot(\%access, $edit_user_scope, $unituser)) {
print ui_table_row(hlink($text{'systemd_boot'}, "systemd_boot"),
ui_yesno_radio("boot", $u->{'boot'}));
}
# User-scope edits allow linger to be managed alongside the raw unit file.
if ($edit_user_scope) {
my $linger_enabled = user_linger_enabled($unituser);
my $linger_field = systemd_can_linger(\%access, $unituser) ?
ui_yesno_radio("linger", $linger_enabled) :
html_escape($linger_enabled ? $text{'yes'} : $text{'no'});
print ui_table_row(hlink($text{'systemd_linger_user'},
"systemd_linger_user"),
$linger_field);
}
print ui_table_end();
}
if ($in{'new'}) {
# New units only need a create button; runtime actions appear after save.
print ui_form_end([ [ undef, $text{'create'} ] ]);
}
else {
# Keep save, override, runtime and inspection actions in nearby clusters;
# destructive actions stay isolated on the far side of the button row.
my @save_buttons = $unit_file_editable && $can_save_unit ?
( [ undef, $text{'save'} ] ) : ( );
my @control_buttons;
my @inspect_buttons = systemd_can_inspect(
\%access, $edit_user_scope, $unituser) ?
( [ 'status', $text{'edit_statusnow'} ],
[ 'props', $text{'edit_propsnow'} ],
[ 'deps', $text{'edit_depsnow'} ] ) : ( );
my @log_buttons = systemd_can_logs(
\%access, $edit_user_scope, $unituser) ?
( [ 'logs', $text{'edit_logsnow'} ] ) : ( );
# Running units can be stopped, but only restart units where systemd
# supports a restart job type. Some runtime units, such as scopes and
# devices, are externally managed and can only be inspected or stopped.
if (defined($u->{'status'}) && $u->{'status'} == 1) {
push(@control_buttons, [ 'restart', $text{'edit_restartnow'} ])
if (unit_restartable($in{'name'}) &&
systemd_can_runtime(
\%access, 'restart', $edit_user_scope,
$unituser));
push(@control_buttons, [ 'stop', $text{'edit_stopnow'} ])
if (systemd_can_runtime(
\%access, 'stop', $edit_user_scope,
$unituser));
}
elsif (unit_startable($in{'name'}) &&
systemd_can_runtime(\%access, 'start',
$edit_user_scope, $unituser)) {
push(@control_buttons, [ 'start', $text{'edit_startnow'} ]);
}
my @override_buttons;
if ($edit_dropin) {
push(@override_buttons,
[ 'stock_unit',
$text{'edit_stockunitnow'} || "Stock Unit" ]);
}
elsif ($unit_file_editable &&
systemd_can_dropin(\%access, $edit_user_scope, $unituser)) {
my $override_text = dropin_exists($edit_user_scope,
$unituser, $in{'name'}) ?
($text{'edit_editoverridenow'} ||
"Edit Override") :
($text{'edit_overridenow'} ||
"Create Override");
push(@override_buttons, [ 'override', $override_text ]);
}
my @delete_buttons;
if ($edit_dropin && !$dropin_file && $unit_file_editable &&
systemd_can_dropin(\%access, $edit_user_scope, $unituser)) {
push(@delete_buttons,
[ 'delete_override',
$text{'edit_deleteoverridenow'} || "Delete Override" ]);
}
elsif ($unit_file_editable && $in{'name'} ne 'webmin.service' &&
systemd_can_delete(\%access, $edit_user_scope, $unituser)) {
push(@delete_buttons, [ 'delete', $text{'delete'} ]);
}
print ui_form_grouped_buttons([ [ \@save_buttons,
\@override_buttons,
\@control_buttons,
\@inspect_buttons,
\@log_buttons ],
[ \@delete_buttons ] ]);
print ui_form_end();
}
# Return to the index tab that owns this unit when the type or scope is known.
my $footer_url = $in{'new'} ?
index_url(".".$default_unittype, $create_user_scope, $unituser) :
index_url($in{'name'}, $edit_user_scope, $unituser);
ui_print_footer($footer_url, $text{'index_return'});
# edit_runtime_state(active-state, sub-state)
# Returns a systemd-style runtime state value such as "Active (running)".
sub edit_runtime_state
{
my ($state, $substate) = @_;
my $value = edit_state_value($state);
if (defined($state) && $state ne "" &&
defined($substate) && $substate ne "" && $substate ne $state) {
$value .= " ".ui_tag('span',
"(".html_escape(lcfirst($substate)).")");
}
return $value;
}
# edit_state_value(state)
# Returns a formatted systemd state value for the edit form.
sub edit_state_value
{
my ($state) = @_;
return ui_tag('i', $text{'index_unknown'})
if (!defined($state) || $state eq "");
return html_escape(ucfirst($state));
}