diff --git a/mod_core_list.txt b/mod_core_list.txt index fd8c059c9..5108b09a8 100644 --- a/mod_core_list.txt +++ b/mod_core_list.txt @@ -1 +1 @@ -acl apache authentic-theme backup-config bind8 change-user cron dovecot fail2ban fdisk filemin firewalld fsdump gray-theme htaccess-htpasswd init logrotate logviewer lvm mailboxes mailcap mount mysql net package-updates passwd phpini postfix proc procmail proftpd quota servers software spam sshd status system-status time updown useradmin usermin webmin webmincron webminlog xterm \ No newline at end of file +acl apache authentic-theme backup-config bind8 change-user cron dovecot fail2ban fdisk filemin firewalld fsdump gray-theme htaccess-htpasswd init systemd logrotate logviewer lvm mailboxes mailcap mount mysql net package-updates passwd phpini postfix proc procmail proftpd quota servers software spam sshd status system-status time updown useradmin usermin webmin webmincron webminlog xterm \ No newline at end of file diff --git a/mod_full_list.txt b/mod_full_list.txt index a5f8fb4cf..619767c54 100644 --- a/mod_full_list.txt +++ b/mod_full_list.txt @@ -1 +1 @@ -acl adsl-client apache at authentic-theme backup-config bacula-backup bandwidth bind8 bsdexports bsdfdisk change-user cluster-copy cluster-cron cluster-passwd cluster-shell cluster-software cluster-useradmin cluster-usermin cluster-webmin cpan cron custom dfsadmin dhcpd dovecot exim exports fail2ban fdisk fetchmail filemin filter firewall firewall6 firewalld format fsdump gray-theme grub2 heartbeat hpuxexports htaccess-htpasswd idmapd inetd init inittab ipfilter ipfw ipsec iscsi-client iscsi-server iscsi-target iscsi-tgtd kea-dhcp krb5 ldap-client ldap-server ldap-useradmin logrotate logviewer lpadmin lvm mailboxes mailcap man mount mysql net nftables nginx nis openslp package-updates pam pap passwd phpini postfix postgresql ppp-client pptp-client pptp-server proc procmail proftpd qmailadmin quota raid rbac samba sarg sendmail servers sgiexports shell shorewall shorewall6 smart-status smf software spam squid sshd status stunnel syslog syslog-ng system-status tcpwrappers time tunnel updown useradmin usermin webalizer webmin webmincron webminlog xinetd xterm zones +acl adsl-client apache at authentic-theme backup-config bacula-backup bandwidth bind8 bsdexports bsdfdisk change-user cluster-copy cluster-cron cluster-passwd cluster-shell cluster-software cluster-useradmin cluster-usermin cluster-webmin cpan cron custom dfsadmin dhcpd dovecot exim exports fail2ban fdisk fetchmail filemin filter firewall firewall6 firewalld format fsdump gray-theme grub2 heartbeat hpuxexports htaccess-htpasswd idmapd inetd init systemd inittab ipfilter ipfw ipsec iscsi-client iscsi-server iscsi-target iscsi-tgtd kea-dhcp krb5 ldap-client ldap-server ldap-useradmin logrotate logviewer lpadmin lvm mailboxes mailcap man mount mysql net nftables nginx nis openslp package-updates pam pap passwd phpini postfix postgresql ppp-client pptp-client pptp-server proc procmail proftpd qmailadmin quota raid rbac samba sarg sendmail servers sgiexports shell shorewall shorewall6 smart-status smf software spam squid sshd status stunnel syslog syslog-ng system-status tcpwrappers time tunnel updown useradmin usermin webalizer webmin webmincron webminlog xinetd xterm zones diff --git a/systemd/acl_security.pl b/systemd/acl_security.pl new file mode 100644 index 000000000..d076e78ec --- /dev/null +++ b/systemd/acl_security.pl @@ -0,0 +1,81 @@ +use strict; +use warnings; +no warnings 'redefine'; + +require 'systemd-lib.pl'; ## no critic + +our (%in, %text); + +# acl_security_form(options) +# Outputs ACL controls for granting access to systemd unit management. +sub acl_security_form +{ +my ($o) = @_; +my $m = $o->{'mode'} || 0; + +print ui_table_span(ui_tag('b', html_escape($text{'acl_section_users'}))); +print ui_table_row($text{'acl_users'}, + ui_radio("mode", $m, + [ [ 0, "$text{'acl_all'}
" ], + [ 3, "$text{'acl_this'}
" ], + [ 1, $text{'acl_only'}." ". + ui_textbox("userscan", + $m == 1 ? $o->{'users'} : "", 40)." ". + user_chooser_button("userscan", 1)."
" ], + [ 2, $text{'acl_except'}." ". + ui_textbox("userscannot", + $m == 2 ? $o->{'users'} : "", 40)." ". + user_chooser_button("userscannot", 1)."
" ], + [ 5, $text{'acl_gid'}." ". + ui_textbox("gid", + $m == 5 ? scalar(getgrgid($o->{'users'})) : "", 13)." ". + group_chooser_button("gid", 0)."
" ], + [ 4, $text{'acl_uid'}." ". + ui_textbox("uidmin", $o->{'uidmin'}, 6)." - ". + ui_textbox("uidmax", $o->{'uidmax'}, 6)."
" ], + ]), 3, undef, undef, 1); +print ui_table_hr(); + +print ui_table_span(ui_tag('b', html_escape($text{'acl_section_view'}))); +foreach my $a (qw(view view_user status status_user logs logs_user)) { + print ui_table_row($text{'acl_'.$a}, + ui_yesno_radio($a, $o->{$a}), 3); + } +print ui_table_hr(); + +print ui_table_span(ui_tag('b', html_escape($text{'acl_section_runtime'}))); +foreach my $a (qw(start start_user stop stop_user restart restart_user + boot boot_user mask mask_user reload linger)) { + print ui_table_row($text{'acl_'.$a}, + ui_yesno_radio($a, $o->{$a}), 3); + } +print ui_table_hr(); + +print ui_table_span(ui_tag('b', html_escape($text{'acl_section_change'}))); +foreach my $a (qw(create create_user edit edit_user delete delete_user + dropin dropin_user manual manual_user backup)) { + print ui_table_row($text{'acl_'.$a}, + ui_yesno_radio($a, $o->{$a}), 3); + } +} + +# acl_security_save(options) +# Saves systemd ACL settings from the submitted form. +sub acl_security_save +{ +my ($o) = @_; + +my $mode = defined($in{'mode'}) && $in{'mode'} =~ /^[0-5]$/ ? + $in{'mode'} : 0; +$o->{'mode'} = $mode; +$o->{'users'} = $mode == 0 || $mode == 3 || $mode == 4 ? "" : + $mode == 5 ? scalar(getgrnam($in{'gid'} || "")) || "" : + $mode == 1 ? $in{'userscan'} || "" : $in{'userscannot'} || ""; +$o->{'uidmin'} = $mode == 4 ? $in{'uidmin'} || "" : ""; +$o->{'uidmax'} = $mode == 4 ? $in{'uidmax'} || "" : ""; +foreach my $a (systemd_acl_keys()) { + $o->{$a} = $in{$a} || 0; + } +} + +1; diff --git a/systemd/backup_config.pl b/systemd/backup_config.pl new file mode 100644 index 000000000..425c6b4b1 --- /dev/null +++ b/systemd/backup_config.pl @@ -0,0 +1,83 @@ +use strict; +use warnings; + +require 'systemd-lib.pl'; ## no critic + +our %access; + +# backup_config_files() +# Returns local system and user unit files that should be included in backups. +sub backup_config_files +{ +my @rv; +my %seen; +return @rv if (!systemd_acl_bool(\%access, 'backup')); +my $add_file = sub { + my ($file) = @_; + push(@rv, $file) if ($file && !$seen{$file}++); + }; + +# System unit backups should only include locally managed files under /etc. +foreach my $u (list_units()) { + $add_file->($u->{'file'}) + if ($u->{'file'} && $u->{'file'} =~ m!^/etc/systemd/system/!); + my $name = backup_unit_name($u); + if ($name && dropin_exists(0, undef, $name)) { + $add_file->(system_dropin_file($name)); + } + } + +# User units live under home directories and are safe to include by path. +foreach my $u (list_all_user_units()) { + next if (!systemd_acl_user_allowed(\%access, $u->{'user'})); + $add_file->($u->{'file'}) if ($u->{'file'}); + my $name = backup_unit_name($u); + if ($name && dropin_exists(1, $u->{'user'}, $name)) { + $add_file->(user_dropin_file($u->{'user'}, $name)); + } + } +return @rv; +} + +# backup_unit_name(unit) +# Returns the safe unit name from a listed unit row. +sub backup_unit_name +{ +my ($u) = @_; +return $u->{'name'} if ($u->{'name'} && valid_unit_name($u->{'name'})); +if ($u->{'file'} && $u->{'file'} =~ m{/([^/]+)$} && + valid_unit_name($1)) { + return $1; + } +return; +} + +# pre_backup() +# No preparation is needed before Webmin copies systemd unit files. +sub pre_backup +{ +return; +} + +# post_backup() +# No cleanup is needed after Webmin copies systemd unit files. +sub post_backup +{ +return; +} + +# pre_restore() +# No preparation is needed before Webmin restores systemd unit files. +sub pre_restore +{ +return; +} + +# post_restore() +# Reloads systemd after restored unit files are back on disk. +sub post_restore +{ +reload_manager(); +} + +1; diff --git a/systemd/config b/systemd/config new file mode 100644 index 000000000..a316c83e5 --- /dev/null +++ b/systemd/config @@ -0,0 +1,11 @@ +desc=1 +logs_lines=200 +logs_current_boot=0 +visible_tabs=service,timer,socket,path,target,storage,resources,device,user +show_runtime_units=1 +default_create_scope=system +manual_vendor_units=1 +default_linger=1 +show_unit_suffixes=0 +show_dropin_inventory=1 +create_return_index=0 diff --git a/systemd/config.info b/systemd/config.info new file mode 100644 index 000000000..dd1407c7e --- /dev/null +++ b/systemd/config.info @@ -0,0 +1,11 @@ +desc=Display unit descriptions,1,1-Yes,0-No +logs_lines=Number of journal log lines to show,0 +logs_current_boot=Journal log scope,1,1-Current boot only,0-All available logs +visible_tabs=Tabs to show on the index page,15,visible_tabs +show_runtime_units=Show generated and transient units,1,1-Yes,0-No +default_create_scope=Default scope for new units,1,system-System units,user-User units +manual_vendor_units=Include vendor unit files in the manual editor,1,1-Yes,0-No +default_linger=Enable linger by default for new user units,1,1-Yes,0-No +show_unit_suffixes=Show full unit names with type suffixes,1,1-Yes,0-No +show_dropin_inventory=Show drop-in override inventory,1,1-Yes,0-No +create_return_index=Return to index after creating a new unit,1,1-Yes,0-No diff --git a/systemd/config_info.pl b/systemd/config_info.pl new file mode 100644 index 000000000..e5e454486 --- /dev/null +++ b/systemd/config_info.pl @@ -0,0 +1,34 @@ +use strict; +use warnings; + +require './systemd-lib.pl'; ## no critic + +our (%in, %text); + +# show_visible_tabs(value) +# Returns checkbox controls for choosing which index tabs are visible. +sub show_visible_tabs +{ +my ($value) = @_; +my %enabled = map { $_, 1 } split(/\s*,\s*/, $value || default_visible_tabs()); +my @fields; +foreach my $tab (get_index_tab_ids()) { + my $label = $text{'systemd_tab_'.$tab} || ucfirst($tab); + push(@fields, ui_checkbox( + "visible_tabs", $tab, $label, $enabled{$tab})); + } +return join(" ", @fields); +} + +# parse_visible_tabs(value) +# Returns selected tab IDs, and rejects saving with every tab hidden. +sub parse_visible_tabs +{ +my @tabs = split(/\0/, $in{'visible_tabs'} || ""); +my %valid = map { $_, 1 } get_index_tab_ids(); +@tabs = grep { $valid{$_} } @tabs; +@tabs || error($text{'systemd_evisibletabs'}); +return join(",", @tabs); +} + +1; diff --git a/systemd/defaultacl b/systemd/defaultacl new file mode 100644 index 000000000..6ba026d80 --- /dev/null +++ b/systemd/defaultacl @@ -0,0 +1,34 @@ +view=1 +view_user=1 +status=1 +status_user=1 +logs=1 +logs_user=1 +start=1 +start_user=1 +stop=1 +stop_user=1 +restart=1 +restart_user=1 +boot=1 +boot_user=1 +mask=1 +mask_user=1 +create=1 +create_user=1 +edit=1 +edit_user=1 +delete=1 +delete_user=1 +dropin=1 +dropin_user=1 +manual=1 +manual_user=1 +reload=1 +linger=1 +backup=1 +mode=0 +users= +uidmin= +uidmax= +noconfig=0 diff --git a/systemd/dropins.cgi b/systemd/dropins.cgi new file mode 100755 index 000000000..735814772 --- /dev/null +++ b/systemd/dropins.cgi @@ -0,0 +1,122 @@ +#!/usr/local/bin/perl +# Show an inventory of discovered systemd drop-in override files. + +use strict; +use warnings; + +require './systemd-lib.pl'; ## no critic + +our (%access, %config, %text); + +ReadParse(); + +has_command("systemctl") || error($text{'systemd_esystemctl'}); +systemd_can_enter_module(\%access) || systemd_acl_error('penter'); +$config{'show_dropin_inventory'} || error($text{'dropins_disabled'}); + +my $can_system = systemd_can_view_system(\%access); +my $can_user = systemd_can_view_user_scope(\%access); + +my @system_units = $can_system ? list_units() : ( ); +my %system_units = map { $_->{'name'}, $_ } @system_units; +my @user_units = $can_user ? + grep { systemd_acl_user_allowed(\%access, $_->{'user'}) } + list_all_user_units() : ( ); +my %user_units = map { $_->{'user'}."\t".$_->{'name'}, $_ } @user_units; + +my @dropins; +push(@dropins, list_system_dropin_override_files()) if ($can_system); +if ($can_user) { + foreach my $dropin (list_all_user_dropin_override_files()) { + next if (!systemd_acl_user_allowed(\%access, $dropin->{'user'})); + push(@dropins, $dropin); + } + } +@dropins = sort { $a->{'scope'} cmp $b->{'scope'} || + ($a->{'user'} || "") cmp ($b->{'user'} || "") || + $a->{'unit'} cmp $b->{'unit'} || + $a->{'file'} cmp $b->{'file'} } @dropins; + +ui_print_header(undef, $text{'dropins_title'}, "", "intro", undef, 1, + undef, action_links()); + +print ui_tag('p', $text{'dropins_desc'}); +if (!@dropins) { + print ui_tag('p', $text{'dropins_empty'}); + } +else { + print_dropin_table(\@dropins, \%system_units, \%user_units); + } + +ui_print_footer("index.cgi", $text{'index_return'}); + +# print_dropin_table(dropins, system-units, user-units) +# Outputs the discovered drop-in inventory table. +sub print_dropin_table +{ +my ($dropins, $system_units, $user_units) = @_; +print ui_columns_start([ + $text{'systemd_name'}, + $text{'dropins_scope'}, + $text{'systemd_owner'}, + $text{'dropins_file'}, + $text{'dropins_actions'}, + ]); +foreach my $dropin (@$dropins) { + print ui_columns_row([ + ui_tag('tt', html_escape($dropin->{'unit'})), + html_escape(dropin_scope_label($dropin)), + $dropin->{'scope'} eq 'user' ? + ui_tag('tt', html_escape($dropin->{'user'})) : + html_escape("-"), + ui_tag('tt', html_escape($dropin->{'file'})), + dropin_action_link($dropin, $system_units, $user_units), + ]); + } +print ui_columns_end(); +} + +# dropin_scope_label(dropin) +# Returns a human-readable scope label for a drop-in descriptor. +sub dropin_scope_label +{ +my ($dropin) = @_; +return $dropin->{'scope'} eq 'user' ? + $text{'dropins_scope_user'} : $text{'dropins_scope_system'}; +} + +# dropin_action_link(dropin, system-units, user-units) +# Returns an edit action when the discovered drop-in belongs to a known unit. +sub dropin_action_link +{ +my ($dropin, $system_units, $user_units) = @_; +if ($dropin->{'scope'} eq 'user') { + my $user = $dropin->{'user'}; + my $unit = $dropin->{'unit'}; + return ui_tag('i', html_escape($text{'dropins_unit_missing'})) + if (!$user_units->{$user."\t".$unit}); + return ui_tag('i', html_escape($text{'dropins_view_only'})) + if (!systemd_can_dropin(\%access, 1, $user)); + my $url = "edit_unit.cgi?scope=user&unituser=".urlize($user). + "&name=".urlize($unit)."&dropin=1". + dropin_file_arg($dropin); + return ui_link($url, $text{'dropins_edit'}); + } +my $unit = $dropin->{'unit'}; +return ui_tag('i', html_escape($text{'dropins_unit_missing'})) + if (!$system_units->{$unit}); +return ui_tag('i', html_escape($text{'dropins_view_only'})) + if (!systemd_can_dropin(\%access, 0)); +return ui_link("edit_unit.cgi?name=".urlize($unit)."&dropin=1". + dropin_file_arg($dropin), + $text{'dropins_edit'}); +} + +# dropin_file_arg(dropin) +# Returns an exact drop-in file query argument for non-standard drop-ins. +sub dropin_file_arg +{ +my ($dropin) = @_; +return "" if ($dropin->{'standard'}); +return "&dropfile=".urlize($dropin->{'file'}); +} diff --git a/systemd/edit_manual.cgi b/systemd/edit_manual.cgi new file mode 100755 index 000000000..1ae201149 --- /dev/null +++ b/systemd/edit_manual.cgi @@ -0,0 +1,70 @@ +#!/usr/local/bin/perl +# Show a page for manually editing discovered systemd unit files. + +use strict; +use warnings; + +require './systemd-lib.pl'; ## no critic + +our (%access, %in, %text); + +ReadParse(); +error_setup($text{'manual_edit_err'}); + +systemd_acl_bool(\%access, 'manual') || + systemd_acl_bool(\%access, 'manual_user') || + systemd_acl_error('pmanual'); + +# File choices are constrained to discovered system and local user unit files. +my @files = grep { systemd_can_manual(\%access, $_) } list_manual_unit_files(); +@files || error(manual_empty_message()); +my %allowed = map { $_->{'file'}, $_ } @files; +my $info = $allowed{$in{'file'}} || $files[0]; +my $file = $info->{'file'}; +my $data = read_manual_unit_file($info); +defined($data) || error($text{'manual_eread'}); + +ui_print_header(undef, $text{'manual_title'}, ""); +my $desc = $info->{'scope'} eq 'user' ? + text('manual_desc_user', + ui_tag('tt', html_escape($info->{'user'}))) : + $text{'manual_desc'}; +print ui_div($desc); + +# Keep the selector separate so changing files does not submit edits. +print ui_form_start("edit_manual.cgi"); +print ui_tag('b', html_escape($text{'manual_select'})); +print ui_select("file", $file, + [ map { [ $_->{'file'}, manual_unit_file_label($_) ] } @files ]); +print " ", ui_submit($text{'manual_ok'}); +print ui_form_end(); + +# The editor preserves raw unit text; validation is limited to the file path. +print ui_form_start("save_manual.cgi", "form-data"); +print ui_hidden("file", $file); +print ui_table_start(undef, undef, 2); +print ui_table_row(undef, ui_textarea("data", $data, 35, 120), 2); +print ui_table_end(); +print ui_form_end([ [ "save", $text{'save'} ] ]); + +ui_print_footer("index.cgi", $text{'index_return'}); + +# manual_unit_file_label(info) +# Returns the selector label for a manual-edit unit file. +sub manual_unit_file_label +{ +my ($info) = @_; +return html_escape($info->{'file'}); +} + +# manual_empty_message() +# Returns an empty-state message for the current manual-edit ACL scope. +sub manual_empty_message +{ +my $user = systemd_acl_default_user(\%access); +return text('manual_enone_user', + ui_tag('tt', html_escape($user))) + if ($user && systemd_acl_bool(\%access, 'manual_user') && + !systemd_acl_bool(\%access, 'manual')); +return $text{'manual_enone'}; +} diff --git a/systemd/edit_unit.cgi b/systemd/edit_unit.cgi new file mode 100755 index 000000000..dbee6eb9a --- /dev/null +++ b/systemd/edit_unit.cgi @@ -0,0 +1,1136 @@ +#!/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'}, $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)); +} diff --git a/systemd/help/config_create_return_index.html b/systemd/help/config_create_return_index.html new file mode 100644 index 000000000..3b4fd1a5d --- /dev/null +++ b/systemd/help/config_create_return_index.html @@ -0,0 +1,4 @@ +
Return to index after creating a new unit
+

Controls where the module goes after successfully creating a unit. When set +to yes, it returns to the matching index tab. When set to no, it opens the +new unit's edit page for immediate review and follow-up actions.

diff --git a/systemd/help/config_default_create_scope.html b/systemd/help/config_default_create_scope.html new file mode 100644 index 000000000..67427f8d0 --- /dev/null +++ b/systemd/help/config_default_create_scope.html @@ -0,0 +1,4 @@ +
Default scope for new units
+

Sets the default scope used when the create-unit form is opened without an +explicit system or user context. Links from index tabs still keep their own +context, such as opening user-unit creation from the User units tab.

diff --git a/systemd/help/config_default_linger.html b/systemd/help/config_default_linger.html new file mode 100644 index 000000000..461ca05ad --- /dev/null +++ b/systemd/help/config_default_linger.html @@ -0,0 +1,4 @@ +
Enable linger by default for new user units
+

Controls the default linger choice when creating new user units. Linger +allows the user's systemd manager and enabled user units to run without an +active login session.

diff --git a/systemd/help/config_desc.html b/systemd/help/config_desc.html new file mode 100644 index 000000000..cf4107823 --- /dev/null +++ b/systemd/help/config_desc.html @@ -0,0 +1,3 @@ +
Display unit descriptions
+

Controls whether unit descriptions are shown in the index tables. Disable +this for a denser table when names and states are enough.

diff --git a/systemd/help/config_logs_current_boot.html b/systemd/help/config_logs_current_boot.html new file mode 100644 index 000000000..e588cbf32 --- /dev/null +++ b/systemd/help/config_logs_current_boot.html @@ -0,0 +1,4 @@ +
Journal log scope
+

Controls whether the Logs action reads entries from the current boot only +or from all journal history available on the system. Current-boot logs are +usually faster and avoid mixing output from previous starts of the machine.

diff --git a/systemd/help/config_logs_lines.html b/systemd/help/config_logs_lines.html new file mode 100644 index 000000000..534362fa9 --- /dev/null +++ b/systemd/help/config_logs_lines.html @@ -0,0 +1,4 @@ +
Number of journal log lines to show
+

The number of recent journal lines to read for each selected unit when using +the Logs action. Larger values provide more history but can make log pages +longer or slower.

diff --git a/systemd/help/config_manual_vendor_units.html b/systemd/help/config_manual_vendor_units.html new file mode 100644 index 000000000..179cc71a4 --- /dev/null +++ b/systemd/help/config_manual_vendor_units.html @@ -0,0 +1,5 @@ +
Include vendor unit files in the manual editor
+

Controls whether the manual file editor includes packaged unit files from +vendor directories such as /usr/lib/systemd/system. These files are +useful for inspection, but normal customizations should usually be made with +drop-in overrides or local units under /etc/systemd/system.

diff --git a/systemd/help/config_show_dropin_inventory.html b/systemd/help/config_show_dropin_inventory.html new file mode 100644 index 000000000..e4ab2ef7e --- /dev/null +++ b/systemd/help/config_show_dropin_inventory.html @@ -0,0 +1,8 @@ +
Show drop-in override inventory
+

Controls whether the module index displays the drop-in overrides inventory +action view.

+ +

The inventory lists discovered drop-in override files for scopes the +current Webmin user can view. Edit links are shown for safe drop-ins attached +to known units only when the matching system or user drop-in ACL permits +management of those overrides.

diff --git a/systemd/help/config_show_runtime_units.html b/systemd/help/config_show_runtime_units.html new file mode 100644 index 000000000..8c4bf86af --- /dev/null +++ b/systemd/help/config_show_runtime_units.html @@ -0,0 +1,4 @@ +
Show generated and transient units
+

Controls whether index tabs include units created dynamically by systemd, +such as transient scopes and generated units. Hiding them keeps the index +focused on persistent unit files that administrators normally edit.

diff --git a/systemd/help/config_show_unit_suffixes.html b/systemd/help/config_show_unit_suffixes.html new file mode 100644 index 000000000..6413e42c9 --- /dev/null +++ b/systemd/help/config_show_unit_suffixes.html @@ -0,0 +1,4 @@ +
Show full unit names with type suffixes
+

Controls whether index tables display full unit names such as +sshd.service or shorter base names such as sshd. When +suffixes are hidden, mixed tabs add a Unit type column where needed.

diff --git a/systemd/help/config_visible_tabs.html b/systemd/help/config_visible_tabs.html new file mode 100644 index 000000000..8eea77e00 --- /dev/null +++ b/systemd/help/config_visible_tabs.html @@ -0,0 +1,4 @@ +
Tabs to show on the index page
+

Selects which unit groups appear on the index page. At least one tab must +remain enabled. Hiding a tab only removes it from the index view; it does not +delete, disable, or mask any units.

diff --git a/systemd/help/intro.html b/systemd/help/intro.html new file mode 100644 index 000000000..4bf049088 --- /dev/null +++ b/systemd/help/intro.html @@ -0,0 +1,12 @@ +
Introduction
+

This module manages units controlled by systemd, including services, timers, +sockets, paths, targets, storage units, resource-control units, devices and user +units. The index groups units by type and shows both the unit file state, such +as enabled, disabled, static or masked, and the runtime state reported by +systemd.

+

Use the unit tables to start, stop, restart, enable, disable, mask, inspect +status or read logs. Existing units can be edited directly when their unit files +are writable, or customized with drop-in override files when packaged base units +should be left intact. User units are managed through the owning user's systemd +manager, with linger controls for units that should keep running without an +active login.

diff --git a/systemd/help/systemd_after.html b/systemd/help/systemd_after.html new file mode 100644 index 000000000..925548778 --- /dev/null +++ b/systemd/help/systemd_after.html @@ -0,0 +1,6 @@ +
Start after units
+

Units that should be started before this unit. This writes +After= in the [Unit] section.

+

Enter space-separated unit names, such as network-online.target +postgresql.service. This only controls ordering when both units are being +started. It does not by itself start the listed units.

diff --git a/systemd/help/systemd_automountidle.html b/systemd/help/systemd_automountidle.html new file mode 100644 index 000000000..0a84da9a1 --- /dev/null +++ b/systemd/help/systemd_automountidle.html @@ -0,0 +1,4 @@ +
Idle timeout
+

Optional systemd duration after which an idle automount is unmounted, such as +30s, 5min, or 1h. Leave blank to use systemd's +default behavior.

diff --git a/systemd/help/systemd_automountmode.html b/systemd/help/systemd_automountmode.html new file mode 100644 index 000000000..6932758ad --- /dev/null +++ b/systemd/help/systemd_automountmode.html @@ -0,0 +1,3 @@ +
Directory mode
+

Optional octal mode used if systemd creates the automount directory, such as +0755 or 0700.

diff --git a/systemd/help/systemd_automountmount.html b/systemd/help/systemd_automountmount.html new file mode 100644 index 000000000..822aeb1f8 --- /dev/null +++ b/systemd/help/systemd_automountmount.html @@ -0,0 +1,6 @@ +
Existing mount unit
+

Select a matching .mount unit for this automount. The automount +unit name and path will be derived from the selected mount unit. For example, +mnt-data.mount pairs with mnt-data.automount.

+

If no matching mount is selected, enter an automount path instead. A matching +.mount unit for that path must already exist.

diff --git a/systemd/help/systemd_automountwhere.html b/systemd/help/systemd_automountwhere.html new file mode 100644 index 000000000..0f315884b --- /dev/null +++ b/systemd/help/systemd_automountwhere.html @@ -0,0 +1,4 @@ +
Automount path
+

The absolute path watched by the automount unit. Accessing this path causes +systemd to activate the matching .mount unit. The matching mount must +use the same path in its Where= setting.

diff --git a/systemd/help/systemd_before.html b/systemd/help/systemd_before.html new file mode 100644 index 000000000..20cedecb3 --- /dev/null +++ b/systemd/help/systemd_before.html @@ -0,0 +1,7 @@ +
Start before units
+

Units that should be ordered after this unit. This writes +Before= in the [Unit] section.

+

Enter space-separated unit names, such as nginx.service +myapp.target. Ordering alone does not cause the other unit to start; pair +it with Wants= or Requires= when this unit should also +pull that unit into the same start transaction.

diff --git a/systemd/help/systemd_boot.html b/systemd/help/systemd_boot.html new file mode 100644 index 000000000..a26ae9cc7 --- /dev/null +++ b/systemd/help/systemd_boot.html @@ -0,0 +1,9 @@ +
Start at boot time?
+

Controls whether the unit is enabled. System units are enabled for the +selected install target; user units are enabled in the selected user's systemd +manager.

+

For user units, enabling the unit is separate from linger. Enable linger +when the unit should be able to start at boot or keep running after the user +logs out.

+

This option is not shown for unit types or unit file states that systemd +cannot enable directly, such as transient, generated, scope, or device units.

diff --git a/systemd/help/systemd_conf.html b/systemd/help/systemd_conf.html new file mode 100644 index 000000000..8855afe13 --- /dev/null +++ b/systemd/help/systemd_conf.html @@ -0,0 +1,9 @@ +
Systemd unit configuration
+

The raw unit file or drop-in override contents. For editable units, changes +made here are saved directly to the selected file.

+

Use this for options that are not exposed by the form. After saving, the +system or user systemd manager is reloaded as appropriate so it sees the +updated unit definition.

+

Runtime-managed units, such as transient scope units and generated units, are +shown read-only because systemd creates those files dynamically. Use the status, +properties, dependencies and log buttons to inspect them.

diff --git a/systemd/help/systemd_conflicts.html b/systemd/help/systemd_conflicts.html new file mode 100644 index 000000000..35e5783a9 --- /dev/null +++ b/systemd/help/systemd_conflicts.html @@ -0,0 +1,8 @@ +
Conflicts with units
+

Units that cannot run at the same time as this unit. Starting one side +causes systemd to stop the other.

+

Enter space-separated unit names. This is useful for mutually exclusive +implementations, such as two services that bind the same port or manage the +same resource.

+

Use After= or Before= as well if the stop/start order +matters when switching between conflicting units.

diff --git a/systemd/help/systemd_desc.html b/systemd/help/systemd_desc.html new file mode 100644 index 000000000..942428a26 --- /dev/null +++ b/systemd/help/systemd_desc.html @@ -0,0 +1,5 @@ +
Unit description
+

A short human-readable unit description written as Description= +in the unit's [Unit] section.

+

This text appears in commands such as systemctl status. It is only +a label and does not change ordering, startup behavior, or logging.

diff --git a/systemd/help/systemd_env.html b/systemd/help/systemd_env.html new file mode 100644 index 000000000..b4621d0ce --- /dev/null +++ b/systemd/help/systemd_env.html @@ -0,0 +1,9 @@ +
Environment variables
+

Environment variables to pass to the service, written as +Environment=. Use systemd's normal assignment syntax, such as +NAME=value.

+

Enter one or more assignments separated by spaces, for example +NODE_ENV=production PORT=3000. Quote values that contain spaces, such +as APP_NAME="My App".

+

For larger or secret-bearing sets of values, prefer an environment file and +set permissions on that file carefully.

diff --git a/systemd/help/systemd_envfile.html b/systemd/help/systemd_envfile.html new file mode 100644 index 000000000..6ae5edce1 --- /dev/null +++ b/systemd/help/systemd_envfile.html @@ -0,0 +1,11 @@ +
Environment file
+

Absolute path to a file containing environment variables for the service. +This writes EnvironmentFile=. Prefix the path with - to +ignore a missing file.

+

Each line in the file should normally be a shell-style assignment such as +NAME=value. Common examples are /etc/default/myapp, +/etc/sysconfig/myapp, or a private file under the application's +directory.

+

For user units, use an absolute path to a file readable by the selected +user, typically below that user's home directory such as +/home/example/.config/myapp/environment.

diff --git a/systemd/help/systemd_file.html b/systemd/help/systemd_file.html new file mode 100644 index 000000000..66404d7ee --- /dev/null +++ b/systemd/help/systemd_file.html @@ -0,0 +1,8 @@ +
Configuration file
+

The path to the systemd unit file or drop-in override file currently being +edited.

+

System units are normally stored under a system unit directory. User +units created here are stored below the selected user's +~/.config/systemd/user directory.

+

Packaged vendor unit files may be shown for inspection, but normal local +changes should usually be made with a drop-in override or a local unit file.

diff --git a/systemd/help/systemd_group.html b/systemd/help/systemd_group.html new file mode 100644 index 000000000..66aedcd93 --- /dev/null +++ b/systemd/help/systemd_group.html @@ -0,0 +1,5 @@ +
Run as group
+

For system services, writes Group= so the service process runs with +the selected Unix group. This option is hidden for user units.

+

Leave this empty to use the selected user's default group. Set it only when +the service needs a specific primary group for file or socket access.

diff --git a/systemd/help/systemd_killmode.html b/systemd/help/systemd_killmode.html new file mode 100644 index 000000000..75132a41a --- /dev/null +++ b/systemd/help/systemd_killmode.html @@ -0,0 +1,11 @@ +
Kill mode
+

Controls how systemd terminates processes belonging to the service. This +writes KillMode=.

+

control-group is the default and safest choice: systemd stops the +main process and any remaining child processes in the service cgroup. +mixed sends the first termination signal only to the main process, +then later kills remaining cgroup processes if needed.

+

process stops only the main process and can leave child processes +behind. none makes systemd run the stop command but not kill service +processes. Avoid process and none unless you know the +application manages its own process tree safely.

diff --git a/systemd/help/systemd_limitnofile.html b/systemd/help/systemd_limitnofile.html new file mode 100644 index 000000000..0d385487c --- /dev/null +++ b/systemd/help/systemd_limitnofile.html @@ -0,0 +1,6 @@ +
Open files limit
+

Sets the service file descriptor limit with LimitNOFILE=.

+

Enter a number such as 65535, infinity, or a +soft:hard pair such as 4096:65535. This is commonly needed by busy +web servers, proxies, databases, and applications that keep many sockets or +files open.

diff --git a/systemd/help/systemd_linger.html b/systemd/help/systemd_linger.html new file mode 100644 index 000000000..0c90e523f --- /dev/null +++ b/systemd/help/systemd_linger.html @@ -0,0 +1,8 @@ +
Enable linger for this user?
+

Controls systemd linger, implemented by +loginctl enable-linger for the selected user. This allows the user's +systemd manager and enabled user units to run after the user logs out and to +start at boot.

+

Without linger, a user unit normally requires an active login session or an +already-running user manager. This is fine for desktop/session units, but +server-style user units usually need linger enabled.

diff --git a/systemd/help/systemd_linger_user.html b/systemd/help/systemd_linger_user.html new file mode 100644 index 000000000..37c260fad --- /dev/null +++ b/systemd/help/systemd_linger_user.html @@ -0,0 +1,7 @@ +
Allow user units to run without login?
+

Controls whether this user's systemd manager is allowed to continue running +without an active login session. This is useful for user units that should keep +running after the user logs out or start at boot.

+

This is systemd linger for the unit owner. Enabling a user unit at boot and +allowing it to run without login are separate settings; server-style user units +usually need both.

diff --git a/systemd/help/systemd_logerr.html b/systemd/help/systemd_logerr.html new file mode 100644 index 000000000..1ff733783 --- /dev/null +++ b/systemd/help/systemd_logerr.html @@ -0,0 +1,11 @@ +
Standard error
+

Destination for the service standard error stream, written as +StandardError=. Common values include journal, +null, inherit, journal+console, +file:/path/to/file, append:/path/to/file, and +truncate:/path/to/file.

+

Advanced systemd targets such as kmsg, tty, +socket, and fd:name are also accepted.

+

If you enter an absolute file path, it will be written as +append:/path/to/file. Use journal to keep errors in +journalctl, or leave empty to inherit the systemd default.

diff --git a/systemd/help/systemd_logstd.html b/systemd/help/systemd_logstd.html new file mode 100644 index 000000000..cac99ae09 --- /dev/null +++ b/systemd/help/systemd_logstd.html @@ -0,0 +1,11 @@ +
Standard output
+

Destination for the service standard output stream, written as +StandardOutput=. Common values include journal, +null, inherit, journal+console, +file:/path/to/file, append:/path/to/file, and +truncate:/path/to/file.

+

Advanced systemd targets such as kmsg, tty, +socket, and fd:name are also accepted.

+

If you enter an absolute file path, it will be written as +append:/path/to/file so output is appended instead of replacing the +file. Leave empty to use the systemd default.

diff --git a/systemd/help/systemd_main_pid.html b/systemd/help/systemd_main_pid.html new file mode 100644 index 000000000..f3645ccf2 --- /dev/null +++ b/systemd/help/systemd_main_pid.html @@ -0,0 +1,6 @@ +
Main PID
+

The main process ID reported by systemd for this unit. It is shown only when +systemd reports a positive process ID.

+ +

Use the status and properties buttons for the full process and unit state +reported by systemd.

diff --git a/systemd/help/systemd_mountoptions.html b/systemd/help/systemd_mountoptions.html new file mode 100644 index 000000000..9d593187f --- /dev/null +++ b/systemd/help/systemd_mountoptions.html @@ -0,0 +1,4 @@ +
Mount options
+

Optional comma-separated mount options, such as defaults, +noatime, ro, or rw,nosuid,nodev. These are written +as the Options= directive in the [Mount] section.

diff --git a/systemd/help/systemd_mounttype.html b/systemd/help/systemd_mounttype.html new file mode 100644 index 000000000..03fdd6217 --- /dev/null +++ b/systemd/help/systemd_mounttype.html @@ -0,0 +1,4 @@ +
Filesystem type
+

The optional filesystem type passed to systemd, such as ext4, +xfs, nfs, or tmpfs. Leave this blank when systemd +or the mount helper can determine the type automatically.

diff --git a/systemd/help/systemd_mountwhat.html b/systemd/help/systemd_mountwhat.html new file mode 100644 index 000000000..1e03e1e36 --- /dev/null +++ b/systemd/help/systemd_mountwhat.html @@ -0,0 +1,5 @@ +
Mount source
+

The filesystem, block device, network export, or other source to mount. +Examples include UUID=01234567-89ab-cdef-0123-456789abcdef, +/dev/disk/by-label/data, /dev/mapper/vg0-data, +server:/export/path, or tmpfs.

diff --git a/systemd/help/systemd_mountwhere.html b/systemd/help/systemd_mountwhere.html new file mode 100644 index 000000000..1eebe96fc --- /dev/null +++ b/systemd/help/systemd_mountwhere.html @@ -0,0 +1,4 @@ +
Mount point
+

The absolute path where this filesystem will be mounted, such as +/mnt/data. The systemd mount unit name is derived from this path, so +/mnt/data becomes mnt-data.mount.

diff --git a/systemd/help/systemd_name.html b/systemd/help/systemd_name.html new file mode 100644 index 000000000..94e8969d3 --- /dev/null +++ b/systemd/help/systemd_name.html @@ -0,0 +1,10 @@ +
Unit name
+

The systemd unit name to create or edit. When creating a new unit, the suffix +for the selected unit type is appended if it is not already included.

+

Use a unit name such as myapp.service, myjob.timer, or +myapp.socket, not a filesystem path. +This is also the name other unit fields refer to, for example in +After= or WantedBy=.

+

For mount and automount units, the name can be left blank when a mount path +is entered or an existing mount unit is selected. The expected systemd name +will be derived from the mount path.

diff --git a/systemd/help/systemd_nonewprivs.html b/systemd/help/systemd_nonewprivs.html new file mode 100644 index 000000000..fc4f3c80f --- /dev/null +++ b/systemd/help/systemd_nonewprivs.html @@ -0,0 +1,6 @@ +
Prevent gaining new privileges?
+

Writes NoNewPrivileges=yes, preventing the service and its child +processes from gaining additional privileges.

+

This is a low-risk hardening option for many services. Avoid it only when +the application intentionally relies on setuid helpers or other privilege +elevation after startup.

diff --git a/systemd/help/systemd_onfailure.html b/systemd/help/systemd_onfailure.html new file mode 100644 index 000000000..d43306a9f --- /dev/null +++ b/systemd/help/systemd_onfailure.html @@ -0,0 +1,8 @@ +
On failure units
+

Units to activate when this unit enters a failed state. This writes +OnFailure=.

+

Enter space-separated unit names, not commands. For example, +alert-admin@%n.service can start a separate templated service and pass +this unit's name as %n.

+

For services with a restart policy, the failure unit is normally activated +only after systemd gives up and the service becomes failed.

diff --git a/systemd/help/systemd_onsuccess.html b/systemd/help/systemd_onsuccess.html new file mode 100644 index 000000000..74525c8b6 --- /dev/null +++ b/systemd/help/systemd_onsuccess.html @@ -0,0 +1,8 @@ +
On success units
+

Units to activate when this unit finishes successfully. This writes +OnSuccess=.

+

Enter space-separated unit names, not commands. This is mainly useful for +oneshot jobs that should trigger a follow-up unit after completing normally, +such as publish-report.service.

+

This directive was added in systemd 249, so older distributions may ignore +it or log a warning.

diff --git a/systemd/help/systemd_pathchanged.html b/systemd/help/systemd_pathchanged.html new file mode 100644 index 000000000..d03bb15b9 --- /dev/null +++ b/systemd/help/systemd_pathchanged.html @@ -0,0 +1,7 @@ +
Path changed
+

An absolute path for PathChanged=. The path unit activates its target +unit when the file is closed after being written, or when a watched directory +changes.

+

For user units, this path is watched by the selected user's systemd manager. +It should be a path that user can access, typically below the user's home +directory or runtime directory such as /run/user/UID.

diff --git a/systemd/help/systemd_pathdirectorynotempty.html b/systemd/help/systemd_pathdirectorynotempty.html new file mode 100644 index 000000000..e37c858ee --- /dev/null +++ b/systemd/help/systemd_pathdirectorynotempty.html @@ -0,0 +1,6 @@ +
Directory not empty
+

An absolute directory path for DirectoryNotEmpty=. The path unit +activates its target unit when this directory contains at least one entry.

+

For user units, this directory is watched by the selected user's systemd +manager. It should be a directory that user can access, typically below the +user's home directory or runtime directory such as /run/user/UID.

diff --git a/systemd/help/systemd_pathexists.html b/systemd/help/systemd_pathexists.html new file mode 100644 index 000000000..5a462644c --- /dev/null +++ b/systemd/help/systemd_pathexists.html @@ -0,0 +1,6 @@ +
Path exists
+

An absolute path for PathExists=. The path unit activates its target +unit when this file or directory exists.

+

For user units, this path is evaluated by the selected user's systemd +manager. It should be a path that user can access, typically below the user's +home directory or runtime directory such as /run/user/UID.

diff --git a/systemd/help/systemd_pathexistsglob.html b/systemd/help/systemd_pathexistsglob.html new file mode 100644 index 000000000..cf0418dd7 --- /dev/null +++ b/systemd/help/systemd_pathexistsglob.html @@ -0,0 +1,7 @@ +
Path exists glob
+

An absolute shell-style glob for PathExistsGlob=, such as +/var/spool/app/*.ready. The path unit activates its target unit when at +least one matching file or directory exists.

+

For user units, the glob is evaluated by the selected user's systemd +manager. It should match paths that user can access, typically below the user's +home directory or runtime directory such as /run/user/UID.

diff --git a/systemd/help/systemd_pathmakedirectory.html b/systemd/help/systemd_pathmakedirectory.html new file mode 100644 index 000000000..da8ac89f2 --- /dev/null +++ b/systemd/help/systemd_pathmakedirectory.html @@ -0,0 +1,7 @@ +
Create watched directory?
+

When enabled, MakeDirectory=yes is written. systemd will create the +watched directory if it does not already exist. This is useful for watched +directory paths, not for watched files or glob patterns.

+

For user units, the directory is created by the selected user's systemd +manager, with that user's permissions. It cannot create directories that the +user would not otherwise be allowed to create.

diff --git a/systemd/help/systemd_pathmodified.html b/systemd/help/systemd_pathmodified.html new file mode 100644 index 000000000..20959d8da --- /dev/null +++ b/systemd/help/systemd_pathmodified.html @@ -0,0 +1,6 @@ +
Path modified
+

An absolute path for PathModified=. The path unit activates its +target unit when the file or directory is modified.

+

For user units, this path is watched by the selected user's systemd manager. +It should be a path that user can access, typically below the user's home +directory or runtime directory such as /run/user/UID.

diff --git a/systemd/help/systemd_pathunit.html b/systemd/help/systemd_pathunit.html new file mode 100644 index 000000000..86ab75fd0 --- /dev/null +++ b/systemd/help/systemd_pathunit.html @@ -0,0 +1,4 @@ +
Unit to activate
+

The unit activated by this path unit, written as Unit=. Include the +full unit name and suffix, such as reload-config.service. If omitted, +systemd uses the matching service name.

diff --git a/systemd/help/systemd_pidfile.html b/systemd/help/systemd_pidfile.html new file mode 100644 index 000000000..d8dcbe6d9 --- /dev/null +++ b/systemd/help/systemd_pidfile.html @@ -0,0 +1,7 @@ +
PID file
+

Path to a PID file for forking services. This writes +PIDFile=.

+

Use an absolute path to the file written by the daemon after it forks, such +as /run/myapp.pid. This helps systemd identify the main process.

+

For user units, use a path writable by the selected user, typically below +the user's runtime directory such as /run/user/UID.

diff --git a/systemd/help/systemd_privatetmp.html b/systemd/help/systemd_privatetmp.html new file mode 100644 index 000000000..a6cbc4c33 --- /dev/null +++ b/systemd/help/systemd_privatetmp.html @@ -0,0 +1,6 @@ +
Use private temporary directory?
+

Writes PrivateTmp=yes, giving the service a private view of +temporary directories such as /tmp.

+

This helps isolate temporary files from the rest of the system. Do not use +it when the service must share files through /tmp with other +processes.

diff --git a/systemd/help/systemd_protectsystem.html b/systemd/help/systemd_protectsystem.html new file mode 100644 index 000000000..79aaade77 --- /dev/null +++ b/systemd/help/systemd_protectsystem.html @@ -0,0 +1,10 @@ +
Protect system files
+

Restricts write access to system directories using +ProtectSystem=. Stronger values provide stricter filesystem +protection.

+

true makes core system directories such as /usr and +/boot read-only. full also protects /etc. +strict makes the filesystem broadly read-only except for API +filesystems and paths explicitly made writable.

+

Use ReadWritePaths= for directories the service still needs to +modify.

diff --git a/systemd/help/systemd_readwritepaths.html b/systemd/help/systemd_readwritepaths.html new file mode 100644 index 000000000..703e9e22d --- /dev/null +++ b/systemd/help/systemd_readwritepaths.html @@ -0,0 +1,11 @@ +
Writable paths
+

Paths that remain writable when filesystem protection is enabled. This +writes ReadWritePaths=.

+

Enter space-separated absolute paths, such as /var/lib/myapp +/var/log/myapp. This is typically used with ProtectSystem=full +or ProtectSystem=strict.

+

Advanced systemd path prefixes such as - for optional paths are +accepted when needed.

+

For user units, these should be paths the selected user can access, +typically below that user's home directory. They cannot make system +directories writable to the user.

diff --git a/systemd/help/systemd_reload.html b/systemd/help/systemd_reload.html new file mode 100644 index 000000000..4eca2d736 --- /dev/null +++ b/systemd/help/systemd_reload.html @@ -0,0 +1,7 @@ +
Commands to run on reload
+

Commands run when the service is reloaded. These are written as +ExecReload= entries.

+

Enter one command per line. Use this for a real application reload, such as +sending HUP to the main process or running the daemon's own reload +command. Leave empty if the service cannot reload configuration without a +restart.

diff --git a/systemd/help/systemd_remain.html b/systemd/help/systemd_remain.html new file mode 100644 index 000000000..5d92e5d40 --- /dev/null +++ b/systemd/help/systemd_remain.html @@ -0,0 +1,5 @@ +
Remain active after command exits?
+

Writes RemainAfterExit=yes. This is most useful for +oneshot services whose command exits after changing system state.

+

When enabled, systemd keeps the service in the active state after the start +command exits. This lets a later stop command undo the action.

diff --git a/systemd/help/systemd_requires.html b/systemd/help/systemd_requires.html new file mode 100644 index 000000000..e8d2ae2b7 --- /dev/null +++ b/systemd/help/systemd_requires.html @@ -0,0 +1,6 @@ +
Requires units
+

Strong dependencies for this unit. If a required unit fails to start or is +stopped, this unit is also affected.

+

Enter space-separated unit names. Use this when the unit cannot run +without the listed units. Add After= as well when the dependency must +be fully started before this unit starts.

diff --git a/systemd/help/systemd_restart.html b/systemd/help/systemd_restart.html new file mode 100644 index 000000000..6a3b385bc --- /dev/null +++ b/systemd/help/systemd_restart.html @@ -0,0 +1,9 @@ +
Restart policy
+

Controls when systemd restarts the service after it exits. This writes +Restart=.

+

no disables automatic restarts. on-failure restarts after +non-zero exits, signals, timeouts, and watchdog failures. always +restarts after almost any exit except an explicit stop by systemd.

+

on-success, on-abnormal, on-abort, and +on-watchdog are narrower policies. For ordinary server processes, +on-failure is usually the practical choice.

diff --git a/systemd/help/systemd_restartsec.html b/systemd/help/systemd_restartsec.html new file mode 100644 index 000000000..dfcebb3cb --- /dev/null +++ b/systemd/help/systemd_restartsec.html @@ -0,0 +1,6 @@ +
Restart delay
+

Delay before systemd restarts the service, written as +RestartSec=. Values may use systemd time syntax such as 5s +or 1min.

+

Use this with a restart policy to avoid tight restart loops, for example +5s or 30s.

diff --git a/systemd/help/systemd_runtime_state.html b/systemd/help/systemd_runtime_state.html new file mode 100644 index 000000000..233e71a0f --- /dev/null +++ b/systemd/help/systemd_runtime_state.html @@ -0,0 +1,6 @@ +
Runtime state
+

The active state and sub-state reported by systemd, such as +active (running), active (exited), inactive (dead) +or failed.

+ +

Use the status and log buttons for the full systemd and journal output.

diff --git a/systemd/help/systemd_servicetype.html b/systemd/help/systemd_servicetype.html new file mode 100644 index 000000000..90b7e9d9f --- /dev/null +++ b/systemd/help/systemd_servicetype.html @@ -0,0 +1,10 @@ +
Service type
+

The systemd Type= value. Leave as default for ordinary long-running +commands unless the service needs another startup protocol such as +forking, oneshot, or notify.

+

simple treats the started process as the service immediately. +exec is similar but waits until the command has been executed. +forking is for daemons that background themselves and often needs a +PID file. oneshot is for short tasks that exit. dbus and +notify wait for D-Bus ownership or systemd readiness notification. +idle delays execution until other jobs are dispatched.

diff --git a/systemd/help/systemd_slicecpuweight.html b/systemd/help/systemd_slicecpuweight.html new file mode 100644 index 000000000..b64b58bc7 --- /dev/null +++ b/systemd/help/systemd_slicecpuweight.html @@ -0,0 +1,6 @@ +
CPU weight
+

An optional CPUWeight= value from 1 to 10000. +Higher values give this slice a larger share of CPU time when there is +contention.

+

For user units, this applies only within the selected user's systemd +manager and cannot grant CPU share beyond that user's parent cgroup.

diff --git a/systemd/help/systemd_sliceioweight.html b/systemd/help/systemd_sliceioweight.html new file mode 100644 index 000000000..c54d7cfc8 --- /dev/null +++ b/systemd/help/systemd_sliceioweight.html @@ -0,0 +1,6 @@ +
I/O weight
+

An optional IOWeight= value from 1 to 10000. +Higher values give this slice a larger share of I/O bandwidth when there is +contention.

+

For user units, this applies only within the selected user's systemd +manager and cannot grant I/O share beyond that user's parent cgroup.

diff --git a/systemd/help/systemd_slicememorymax.html b/systemd/help/systemd_slicememorymax.html new file mode 100644 index 000000000..8c271a817 --- /dev/null +++ b/systemd/help/systemd_slicememorymax.html @@ -0,0 +1,5 @@ +
Memory maximum
+

An optional MemoryMax= limit for the slice, such as 512M, +2G, or infinity.

+

For user units, this applies only within the selected user's systemd +manager and cannot raise limits imposed by the user's parent cgroup.

diff --git a/systemd/help/systemd_slicetasksmax.html b/systemd/help/systemd_slicetasksmax.html new file mode 100644 index 000000000..282ce8ba0 --- /dev/null +++ b/systemd/help/systemd_slicetasksmax.html @@ -0,0 +1,5 @@ +
Tasks maximum
+

An optional TasksMax= limit for the slice, such as 500 or +infinity.

+

For user units, this applies only within the selected user's systemd +manager and cannot raise limits imposed by the user's parent cgroup.

diff --git a/systemd/help/systemd_socketaccept.html b/systemd/help/systemd_socketaccept.html new file mode 100644 index 000000000..752af66aa --- /dev/null +++ b/systemd/help/systemd_socketaccept.html @@ -0,0 +1,7 @@ +
Accept each connection?
+

When enabled, Accept=yes is written and systemd starts one service +instance for each incoming connection. Leave disabled for the common model where +one service handles all traffic.

+

Per-connection sockets normally activate a template-style service that can +handle instances. Use Accept=no when a single service should receive +all accepted connections from the socket.

diff --git a/systemd/help/systemd_socketgroup.html b/systemd/help/systemd_socketgroup.html new file mode 100644 index 000000000..d1564ceb2 --- /dev/null +++ b/systemd/help/systemd_socketgroup.html @@ -0,0 +1,5 @@ +
Socket group
+

The Unix group name for SocketGroup=. This controls group ownership +of the socket node when systemd creates filesystem sockets or FIFOs.

+

For user units, filesystem sockets and FIFOs are created by the selected +user's systemd manager. This guided field is used only for system units.

diff --git a/systemd/help/systemd_socketlistendatagram.html b/systemd/help/systemd_socketlistendatagram.html new file mode 100644 index 000000000..b3526598b --- /dev/null +++ b/systemd/help/systemd_socketlistendatagram.html @@ -0,0 +1,4 @@ +
Datagram listener
+

A ListenDatagram= endpoint for UDP or datagram sockets. Examples +include 514, 127.0.0.1:10514, or an absolute filesystem +socket path.

diff --git a/systemd/help/systemd_socketlistenfifo.html b/systemd/help/systemd_socketlistenfifo.html new file mode 100644 index 000000000..36cb4e28f --- /dev/null +++ b/systemd/help/systemd_socketlistenfifo.html @@ -0,0 +1,6 @@ +
FIFO listener
+

An absolute path for ListenFIFO=. systemd creates or opens this FIFO +and activates the matching service when data is written to it.

+

For user units, the FIFO is created by the selected user's systemd manager. +Use a path that user can create, typically below the user's home directory or +runtime directory such as /run/user/UID.

diff --git a/systemd/help/systemd_socketlistenstream.html b/systemd/help/systemd_socketlistenstream.html new file mode 100644 index 000000000..05ca58cd9 --- /dev/null +++ b/systemd/help/systemd_socketlistenstream.html @@ -0,0 +1,3 @@ +
Stream listener
+

A ListenStream= endpoint for TCP or stream sockets. Examples include +8080, 127.0.0.1:8080, or an absolute filesystem socket path.

diff --git a/systemd/help/systemd_socketmode.html b/systemd/help/systemd_socketmode.html new file mode 100644 index 000000000..c6717189f --- /dev/null +++ b/systemd/help/systemd_socketmode.html @@ -0,0 +1,4 @@ +
Socket mode
+

The file mode for SocketMode=, such as 0660 or +0600. This applies to filesystem sockets and FIFOs created by +systemd.

diff --git a/systemd/help/systemd_socketservice.html b/systemd/help/systemd_socketservice.html new file mode 100644 index 000000000..750f9249b --- /dev/null +++ b/systemd/help/systemd_socketservice.html @@ -0,0 +1,8 @@ +
Service to activate
+

The service unit activated by this socket, written as Service=. +Include the full service name, such as example.service. If omitted, +systemd uses the matching service name.

+

For the common Accept=no model, this is usually a normal service. +When Accept=yes is enabled, use a service that is designed to run as +one instance per connection, typically a template such as +example@.service.

diff --git a/systemd/help/systemd_socketuser.html b/systemd/help/systemd_socketuser.html new file mode 100644 index 000000000..638827daa --- /dev/null +++ b/systemd/help/systemd_socketuser.html @@ -0,0 +1,6 @@ +
Socket owner
+

The Unix user name for SocketUser=. This controls ownership of the +socket node when systemd creates filesystem sockets or FIFOs.

+

For user units, filesystem sockets and FIFOs are created by the selected +user's systemd manager and are owned by that user. This guided field is used +only for system units.

diff --git a/systemd/help/systemd_start.html b/systemd/help/systemd_start.html new file mode 100644 index 000000000..c95336350 --- /dev/null +++ b/systemd/help/systemd_start.html @@ -0,0 +1,10 @@ +
Commands to run on startup
+

Commands run by systemd when a service starts. These are written as +ExecStart= entries in the unit file and apply only to +.service units.

+

Enter one command per line. For ordinary long-running daemons this is +usually a single absolute command, for example /usr/bin/node +/home/app/server.js.

+

Multiple start commands are best used with Type=oneshot. For other +service types, multiple commands are combined through a shell command so +systemd still has one main process to track.

diff --git a/systemd/help/systemd_startpost.html b/systemd/help/systemd_startpost.html new file mode 100644 index 000000000..572e92558 --- /dev/null +++ b/systemd/help/systemd_startpost.html @@ -0,0 +1,5 @@ +
Commands to run after startup
+

Commands run after the main start command. These are written as +ExecStartPost= entries.

+

Enter one command per line. These run only after systemd considers the main +start command successful according to the selected service type.

diff --git a/systemd/help/systemd_startpre.html b/systemd/help/systemd_startpre.html new file mode 100644 index 000000000..38fda4184 --- /dev/null +++ b/systemd/help/systemd_startpre.html @@ -0,0 +1,6 @@ +
Commands to run before startup
+

Commands run before the main start command. These are written as +ExecStartPre= entries.

+

Enter one command per line. Use this for quick setup checks, migrations, or +directory preparation. Long-running background processes should not be started +from pre-start commands.

diff --git a/systemd/help/systemd_status.html b/systemd/help/systemd_status.html new file mode 100644 index 000000000..79b0f6853 --- /dev/null +++ b/systemd/help/systemd_status.html @@ -0,0 +1,11 @@ +
Status details
+

The unit file state and runtime state reported by systemd. The unit file +state shows whether the unit is enabled, disabled, static, masked or in another +systemd state.

+

The runtime state shows the active state and sub-state, such as +active (running), active (exited), inactive (dead) or +failed. If systemd reports a main process ID, it is shown separately. +Use the status and log buttons for the full systemd and journal output.

+

Units with transient or generated file state are managed by systemd at +runtime. Their contents may be shown for inspection, but they are read-only in +the editor.

diff --git a/systemd/help/systemd_stop.html b/systemd/help/systemd_stop.html new file mode 100644 index 000000000..263b86530 --- /dev/null +++ b/systemd/help/systemd_stop.html @@ -0,0 +1,7 @@ +
Commands to run on shutdown
+

Optional commands run when a service is stopped. These are written as +ExecStop= entries in the unit file and apply only to +.service units.

+

Use this when the application has its own graceful shutdown command. If this +is left empty, systemd stops the service using its normal signal and +KillMode= behavior.

diff --git a/systemd/help/systemd_stoppost.html b/systemd/help/systemd_stoppost.html new file mode 100644 index 000000000..6eb482cd7 --- /dev/null +++ b/systemd/help/systemd_stoppost.html @@ -0,0 +1,6 @@ +
Commands to run after shutdown
+

Commands run after the service stops. These are written as +ExecStopPost= entries.

+

Enter one command per line. These can be used for cleanup, notification, or +removing temporary files, including cases where the service exited +unexpectedly.

diff --git a/systemd/help/systemd_swapoptions.html b/systemd/help/systemd_swapoptions.html new file mode 100644 index 000000000..149c6d148 --- /dev/null +++ b/systemd/help/systemd_swapoptions.html @@ -0,0 +1,3 @@ +
Swap options
+

Optional comma-separated swap options for Options=. These are passed +to the swap activation tools.

diff --git a/systemd/help/systemd_swappriority.html b/systemd/help/systemd_swappriority.html new file mode 100644 index 000000000..a19d374a3 --- /dev/null +++ b/systemd/help/systemd_swappriority.html @@ -0,0 +1,3 @@ +
Swap priority
+

An optional numeric Priority= value. Higher-priority swap areas are +used first. Negative values are allowed by systemd.

diff --git a/systemd/help/systemd_swaptimeoutsec.html b/systemd/help/systemd_swaptimeoutsec.html new file mode 100644 index 000000000..ef3005315 --- /dev/null +++ b/systemd/help/systemd_swaptimeoutsec.html @@ -0,0 +1,3 @@ +
Swap timeout
+

An optional TimeoutSec= value for swap activation, such as +30s or 2min.

diff --git a/systemd/help/systemd_swapwhat.html b/systemd/help/systemd_swapwhat.html new file mode 100644 index 000000000..839a4abca --- /dev/null +++ b/systemd/help/systemd_swapwhat.html @@ -0,0 +1,3 @@ +
Swap device or file
+

The swap device or swap file for What=. Use an absolute path such as +/swapfile or a stable device path under /dev/disk/by-uuid.

diff --git a/systemd/help/systemd_syslogid.html b/systemd/help/systemd_syslogid.html new file mode 100644 index 000000000..bbc1625aa --- /dev/null +++ b/systemd/help/systemd_syslogid.html @@ -0,0 +1,5 @@ +
Log identifier
+

Name used to identify log messages from this service in the systemd journal +or syslog. This writes the systemd SyslogIdentifier= directive.

+

Use a short stable name, such as myapp. This makes log filtering +easier when multiple commands or wrappers write to the journal.

diff --git a/systemd/help/systemd_timeout.html b/systemd/help/systemd_timeout.html new file mode 100644 index 000000000..738c97c6f --- /dev/null +++ b/systemd/help/systemd_timeout.html @@ -0,0 +1,7 @@ +
Startup timeout
+

Maximum time systemd waits for the service to start. This writes +TimeoutStartSec=.

+

Use systemd time syntax such as 30s, 2min, or 0 +to disable the timeout. This matters most for service types where systemd waits +for readiness, such as forking, notify, and +oneshot.

diff --git a/systemd/help/systemd_timeoutstop.html b/systemd/help/systemd_timeoutstop.html new file mode 100644 index 000000000..551ec4657 --- /dev/null +++ b/systemd/help/systemd_timeoutstop.html @@ -0,0 +1,6 @@ +
Shutdown timeout
+

Maximum time systemd waits for the service to stop cleanly. This writes +TimeoutStopSec=.

+

After this timeout, systemd may force termination according to the service +kill settings. Increase it for applications that need time to flush data or +shut down cleanly.

diff --git a/systemd/help/systemd_timeraccuracysec.html b/systemd/help/systemd_timeraccuracysec.html new file mode 100644 index 000000000..5da565ff4 --- /dev/null +++ b/systemd/help/systemd_timeraccuracysec.html @@ -0,0 +1,4 @@ +
Timer accuracy
+

An optional AccuracySec= value. systemd may coalesce timer events +within this window to reduce wakeups. Smaller values are more exact; larger +values are more power-efficient.

diff --git a/systemd/help/systemd_timeronbootsec.html b/systemd/help/systemd_timeronbootsec.html new file mode 100644 index 000000000..045dd2048 --- /dev/null +++ b/systemd/help/systemd_timeronbootsec.html @@ -0,0 +1,4 @@ +
Delay after boot
+

A monotonic delay for OnBootSec=. The timer will fire this long +after the system boots, for example 5min, 1h, or +30s.

diff --git a/systemd/help/systemd_timeroncalendar.html b/systemd/help/systemd_timeroncalendar.html new file mode 100644 index 000000000..b34367265 --- /dev/null +++ b/systemd/help/systemd_timeroncalendar.html @@ -0,0 +1,4 @@ +
Calendar schedule
+

A calendar expression for OnCalendar=. Use values such as +hourly, daily, weekly, or a full systemd calendar +expression such as Mon..Fri 02:30.

diff --git a/systemd/help/systemd_timeronunitactivesec.html b/systemd/help/systemd_timeronunitactivesec.html new file mode 100644 index 000000000..1396972d5 --- /dev/null +++ b/systemd/help/systemd_timeronunitactivesec.html @@ -0,0 +1,4 @@ +
Delay after activation
+

A monotonic delay for OnUnitActiveSec=. The timer will run again +this long after the activated unit last became active, for example +15min or 1h.

diff --git a/systemd/help/systemd_timerpersistent.html b/systemd/help/systemd_timerpersistent.html new file mode 100644 index 000000000..a668605d6 --- /dev/null +++ b/systemd/help/systemd_timerpersistent.html @@ -0,0 +1,6 @@ +
Catch up missed runs?
+

When enabled, Persistent=yes is written. For calendar timers, this +lets systemd run the timer once when it becomes active again if a scheduled run +was missed while the manager was stopped or the system was off.

+

This does not catch up monotonic timers such as OnBootSec= or +OnUnitActiveSec=.

diff --git a/systemd/help/systemd_timerrandomizeddelaysec.html b/systemd/help/systemd_timerrandomizeddelaysec.html new file mode 100644 index 000000000..2743f3059 --- /dev/null +++ b/systemd/help/systemd_timerrandomizeddelaysec.html @@ -0,0 +1,4 @@ +
Randomized delay
+

An optional RandomizedDelaySec= value. systemd will delay each run by +a random amount up to this value, which helps avoid many timers starting at the +same instant.

diff --git a/systemd/help/systemd_timerunit.html b/systemd/help/systemd_timerunit.html new file mode 100644 index 000000000..7a1fc6f90 --- /dev/null +++ b/systemd/help/systemd_timerunit.html @@ -0,0 +1,4 @@ +
Unit to activate
+

The unit activated by this timer, written as Unit=. Include the full +unit name and suffix, such as backup.service. If omitted, systemd uses +the matching service name.

diff --git a/systemd/help/systemd_type.html b/systemd/help/systemd_type.html new file mode 100644 index 000000000..d83d70cec --- /dev/null +++ b/systemd/help/systemd_type.html @@ -0,0 +1,18 @@ +
Unit type
+

The type of system unit to create. The selected type controls the filename +suffix and the type-specific section written to the unit file. Created system +units are written below /etc/systemd/system.

+

service creates a guided [Service] unit for starting and +supervising a process. timer creates a [Timer] unit that +activates another unit on a schedule. socket creates a +[Socket] unit for socket activation. path creates a +[Path] unit that reacts to filesystem changes. target +creates a grouping or synchronization point for other units.

+

mount creates a [Mount] unit for a filesystem mount, and +automount creates a matching [Automount] unit that activates +a mount on demand. swap creates a [Swap] unit for swap space. +slice creates a [Slice] unit for resource-control grouping +and cgroup policy.

+

Scope and device units are normally created by systemd, udev, or other +programs at runtime. They can be inspected when listed by systemd, but they are +not created as persistent unit files here.

diff --git a/systemd/help/systemd_type_user.html b/systemd/help/systemd_type_user.html new file mode 100644 index 000000000..ca865fedb --- /dev/null +++ b/systemd/help/systemd_type_user.html @@ -0,0 +1,21 @@ +
Unit type
+

The type of user unit to create. The selected type controls the filename +suffix and the type-specific section written to the unit file. Created user +units are written below the selected user's +~/.config/systemd/user directory.

+

service creates a guided [Service] unit for starting and +supervising a process as this Unix user. timer creates a +[Timer] unit that activates another user unit on a schedule. +socket creates a [Socket] unit for user-level socket +activation. path creates a [Path] unit that reacts to +filesystem changes visible to this user. target creates a grouping or +synchronization point for other user units.

+

slice creates a [Slice] unit for grouping this user's own +units and applying resource-control settings such as CPUWeight=, +MemoryMax=, TasksMax=, or IOWeight=. These limits +apply only within the user's systemd manager and cannot grant control over +system slices or other users.

+

Mount, automount and swap units are system-manager unit types and are not +available when creating user units. Scope and device units are created at +runtime by systemd or other programs and are not created as persistent unit +files here.

diff --git a/systemd/help/systemd_unit_state.html b/systemd/help/systemd_unit_state.html new file mode 100644 index 000000000..39c053616 --- /dev/null +++ b/systemd/help/systemd_unit_state.html @@ -0,0 +1,7 @@ +
Unit file state
+

The unit file state reported by systemd. It shows whether the unit is +enabled, disabled, static, masked or in another systemd state.

+ +

Units with transient or generated file state are managed by systemd at +runtime. Their contents may be shown for inspection, but they are read-only in +the editor.

diff --git a/systemd/help/systemd_unitconf.html b/systemd/help/systemd_unitconf.html new file mode 100644 index 000000000..fcc15d770 --- /dev/null +++ b/systemd/help/systemd_unitconf.html @@ -0,0 +1,31 @@ +
Type-specific settings
+

Directives for the selected non-service unit type. Enter directives only, +without the section header; the correct section will be written, such as +[Timer], [Socket], [Path], [Mount], +[Automount], [Swap], or [Slice].

+

Mount and automount units have dedicated fields for their common settings. +Use this field only for uncommon directives that are not shown elsewhere in +the form.

+

For a timer, examples include OnCalendar=daily, +Persistent=true, and Unit=myjob.service. If Unit= +is omitted, systemd activates the service with the same base name, such as +myjob.service for myjob.timer.

+

For a socket, examples include ListenStream=8080, +ListenStream=/run/myapp.sock, Accept=false, and +Service=myapp.service. For user units, filesystem socket paths should +be below a location the selected user can write, such as +/run/user/UID. If Service= is omitted, systemd uses the +service with the same base name.

+

For a path unit, examples include PathChanged=/srv/myapp, +PathExists=/var/run/myapp.ready, and Unit=myjob.service. +For user units, watched paths should normally be below the selected user's +home directory or runtime directory.

+

Target units do not need a type-specific settings body in this form. +Dependencies such as Wants=, Requires=, Before=, +and After= are set in the common advanced options.

+

For a swap unit, examples include What=/swapfile and +Priority=10. For a slice unit, use the dedicated fields for +CPU weight, memory maximum, task maximum, and I/O weight; this field can be +used for additional resource controls such as CPUQuota=50% or +MemoryHigh=256M. For user units, slice resource controls apply only +within the selected user's systemd manager.

diff --git a/systemd/help/systemd_unituser.html b/systemd/help/systemd_unituser.html new file mode 100644 index 000000000..bdf9f76ae --- /dev/null +++ b/systemd/help/systemd_unituser.html @@ -0,0 +1,5 @@ +
User service owner
+

The Unix user whose home directory and systemd user manager own the user +unit. The account is resolved with the local password database.

+

The unit file is written below this user's home directory in +~/.config/systemd/user and is owned by that account.

diff --git a/systemd/help/systemd_user.html b/systemd/help/systemd_user.html new file mode 100644 index 000000000..9be7b1e4c --- /dev/null +++ b/systemd/help/systemd_user.html @@ -0,0 +1,7 @@ +
Run as user
+

For system services, writes User= so the service process runs as +the selected Unix user. This option is hidden for user units.

+

Use this when creating a system service that should be managed by the system +manager but should drop privileges before running the application process.

+

This is different from creating a user unit. A user unit is owned and +managed by the user's systemd manager and does not need User=.

diff --git a/systemd/help/systemd_userservice.html b/systemd/help/systemd_userservice.html new file mode 100644 index 000000000..81133c321 --- /dev/null +++ b/systemd/help/systemd_userservice.html @@ -0,0 +1,10 @@ +
Create as user unit?
+

Creates the unit under the selected user's ~/.config/systemd/user +directory and manages it with systemctl --user.

+

User units run inside the user's systemd manager. For service units, +User= and Group= are not written because the user manager +already runs as that user.

+

Choose this for applications that belong to a user account and should use +that user's home directory, environment, and user unit lifecycle. Choose +No for a normal system unit, even if a service unit later uses +User= to drop privileges.

diff --git a/systemd/help/systemd_wantedby.html b/systemd/help/systemd_wantedby.html new file mode 100644 index 000000000..e34a224f4 --- /dev/null +++ b/systemd/help/systemd_wantedby.html @@ -0,0 +1,8 @@ +
Install target
+

The target that should want this unit when it is enabled. This writes +WantedBy= in the [Install] section.

+

For system services, multi-user.target is the usual default. For +user service units, default.target is normally used so the service starts +with the user's default manager target. Timers, sockets, and paths usually use +timers.target, sockets.target, and paths.target +respectively.

diff --git a/systemd/help/systemd_wants.html b/systemd/help/systemd_wants.html new file mode 100644 index 000000000..44a7f35ee --- /dev/null +++ b/systemd/help/systemd_wants.html @@ -0,0 +1,7 @@ +
Wants units
+

Weak dependencies for this unit. When this unit is started, systemd also +tries to start the units listed in Wants=.

+

Enter space-separated unit names. Use this when the listed unit is helpful +but not strictly required. If it fails, this unit may still start.

+

A common pattern is to use both Wants=other.service and +After=other.service so the other unit is pulled in and ordered first.

diff --git a/systemd/help/systemd_watchdogsec.html b/systemd/help/systemd_watchdogsec.html new file mode 100644 index 000000000..6197f0134 --- /dev/null +++ b/systemd/help/systemd_watchdogsec.html @@ -0,0 +1,8 @@ +
Watchdog timeout
+

Enables the systemd watchdog for services that send watchdog notifications. +This writes WatchdogSec=.

+

Only use this for applications that support systemd watchdog pings, usually +through sd_notify(). If the service does not send keep-alive +notifications before the timeout, systemd treats it as failed.

+

This pairs naturally with Type=notify and +Restart=on-watchdog or Restart=on-failure.

diff --git a/systemd/help/systemd_workdir.html b/systemd/help/systemd_workdir.html new file mode 100644 index 000000000..40993f962 --- /dev/null +++ b/systemd/help/systemd_workdir.html @@ -0,0 +1,10 @@ +
Working directory
+

Directory used as the service process current working directory. This writes +WorkingDirectory=.

+

Use an absolute path, such as /srv/myapp. A path beginning with +~ is also accepted by systemd for the service user's home directory, +and a leading - makes a missing directory non-fatal.

+

This is useful for applications that load relative configuration files or +assets.

+

For user units, use a directory that belongs to the selected user, typically +below that user's home directory.

diff --git a/systemd/images/icon.gif b/systemd/images/icon.gif new file mode 100644 index 000000000..7f61a9bc9 Binary files /dev/null and b/systemd/images/icon.gif differ diff --git a/systemd/index.cgi b/systemd/index.cgi new file mode 100755 index 000000000..f5bc07c87 --- /dev/null +++ b/systemd/index.cgi @@ -0,0 +1,522 @@ +#!/usr/local/bin/perl +# Display systemd system and user units grouped by unit type. + +use strict; +use warnings; + +require './systemd-lib.pl'; ## no critic + +our (%access, %config, %in, %text); + +ReadParse(); + +# The index can be reached by GET, so all query input below is either reduced +# to known values or validated before it is used. +has_command("systemctl") || error($text{'systemd_esystemctl'}); +systemd_can_enter_module(\%access) || systemd_acl_error('penter'); + +# Print the page shell before building the tab contents. +ui_print_header(version_title(), + $text{'index_title'}, "", "intro", 1, 1, undef, + action_links()); +print index_style(); + +# Query parameters only choose the active tab and optional user context. +my $scope = $in{'scope'} eq 'user' && tab_visible('user') ? 'user' : ''; +my $unituser = clean_unit_value($in{'unituser'}); +$unituser ||= systemd_acl_default_user(\%access) || ""; +my $unituser_details = $unituser ? + get_user_details($unituser) : undef; +if ($scope eq 'user') { + $unituser_details || error($text{'systemd_euser'}); + systemd_can_view_user_scope(\%access, $unituser) || + systemd_acl_error('pview_user'); + } +$unituser = "" if (!$unituser_details); + +# Load both system and user units so the visible tab set matches reality. +my @system_units = systemd_can_view_system(\%access) ? list_units() : ( ); +my @user_units = tab_visible('user') && + systemd_can_view_user_scope(\%access) ? + grep { systemd_acl_user_allowed(\%access, $_->{'user'}) } + list_all_user_units() : ( ); +my @tabs = index_tabs(\@system_units, \@user_units, $unituser); + +# Pick a valid active tab. Invalid mode values fall back to the first tab. +my %valid_tabs = map { $_->{'id'}, 1 } @tabs; +my $requested = defined($in{'mode'}) ? $in{'mode'} : ""; +my $mode = $requested && $valid_tabs{$requested} ? $requested : + $scope eq 'user' && $valid_tabs{'user'} ? 'user' : + $tabs[0]->{'id'}; +my $formno = 0; + +# When several unit groups exist, render Webmin tabs around each table. +if (@tabs > 1) { + my @uitabs = map { [ $_->{'id'}, $_->{'title'} ] } @tabs; + print ui_tabs_start(\@uitabs, "mode", $mode, 1); + foreach my $tab (@tabs) { + print ui_tabs_start_tab("mode", $tab->{'id'}); + $formno++ if (print_index_tab($tab, $formno)); + print ui_tabs_end_tab("mode", $tab->{'id'}); + } + print ui_tabs_end(1); + } +else { + # A single unit group does not need tab chrome. + print_index_tab($tabs[0], $formno); + } + +print_index_tools(); +ui_print_footer("/", $text{'index'}); + +# version_title() +# Returns the first line of systemctl --version, or a plain fallback. +sub version_title +{ +my $systemctl = has_command("systemctl"); +return $text{'mode_systemd'} if (!$systemctl); + +# Only the first line is useful as a subtitle, for example "systemd 252". +my $out = backquote_command(quotemeta($systemctl)." --version 2>/dev/null"); +return $text{'mode_systemd'} if ($? || !defined($out) || $out eq ""); +my ($first) = split(/\r?\n/, $out, 2); +$first = clean_unit_value($first); +return $first || $text{'mode_systemd'}; +} + +# index_style() +# Returns CSS used by the systemd index fragment. +sub index_style +{ +# The style is emitted in the body so SPA theme navigation applies it too. +return ui_tag('style', + ".systemd_linger_toggle { text-decoration: none; }\n". + ".systemd_linger_toggle .ui_text_color { border-bottom: 1px dotted currentColor; }\n", + { 'type' => 'text/css' }); +} + +# print_index_tools() +# Prints advanced module actions below the unit tables. +sub print_index_tools +{ +my @buttons; +push(@buttons, [ "edit_manual.cgi", + $text{'index_edit_files'}, + $text{'index_edit_filesdesc'} ]) + if (systemd_acl_bool(\%access, 'manual') || + systemd_acl_bool(\%access, 'manual_user')); +push(@buttons, [ "dropins.cgi", + $text{'index_dropins'}, + $text{'index_dropinsdesc'} ]) + if ($config{'show_dropin_inventory'} && + systemd_can_enter_module(\%access)); +push(@buttons, [ "restart.cgi", + $text{'index_reload'}, + $text{'index_reloaddesc'} ]) + if (systemd_can_reload(\%access)); +return if (!@buttons); +print ui_hr(); +print ui_buttons_start(); +foreach my $button (@buttons) { + print ui_buttons_row(@$button); + } +print ui_buttons_end(); +} + +# index_tab_groups() +# Returns the system-unit tab layout and the unit suffixes each tab owns. +sub index_tab_groups +{ +return ( + { 'id' => 'service', 'types' => [ 'service' ], + 'create' => 'service' }, + { 'id' => 'timer', 'types' => [ 'timer' ], + 'create' => 'timer' }, + { 'id' => 'socket', 'types' => [ 'socket' ], + 'create' => 'socket' }, + { 'id' => 'path', 'types' => [ 'path' ], + 'create' => 'path' }, + { 'id' => 'target', 'types' => [ 'target' ], + 'create' => 'target' }, + { 'id' => 'storage', 'types' => [ 'mount', 'automount', 'swap' ], + 'create' => 'mount', 'show_type' => 1 }, + { 'id' => 'resources', 'types' => [ 'slice', 'scope' ], + 'create' => 'slice', 'show_type' => 1, 'inspect_only' => 1 }, + { 'id' => 'device', 'types' => [ 'device' ], + 'inspect_only' => 1, 'selectable' => 0, + 'show_unit_state' => 0 }, + ); +} + +# index_type_tab(type) +# Returns the tab id that owns a unit type. +sub index_type_tab +{ +my ($type) = @_; +foreach my $tab (index_tab_groups()) { + return $tab->{'id'} if (indexof($type, @{$tab->{'types'}}) >= 0); + } +return; +} + +# index_tabs(system-units, user-units, [user]) +# Builds tab metadata for system unit types plus one user-units tab. +sub index_tabs +{ +my ($system_units, $user_units, $unituser) = @_; +my %by_tab; + +# System units are grouped by suffix, with related low-level types combined. +foreach my $u (@$system_units) { + my ($display, $type) = index_name_type($u->{'name'}); + $type = 'service' if (!$type); + next if (indexof($type, get_list_unit_types()) < 0); + next if (!unit_visible_on_index($u)); + my $tabid = index_type_tab($type); + next if (!$tabid); + $u->{'_display'} = $config{'show_unit_suffixes'} ? + $u->{'name'} : $display; + $u->{'_type'} = $type; + push(@{$by_tab{$tabid}}, $u); + } + +# Keep tab ordering stable even when some unit types are absent. +my @tabs; +foreach my $group (index_tab_groups()) { + next if (!tab_visible($group->{'id'})); + next if (!systemd_can_view_system(\%access)); + my $units = $by_tab{$group->{'id'}} || [ ]; + push(@tabs, { %$group, + 'title' => index_tab_title($group->{'id'}), + 'desc' => index_tab_desc($group->{'id'}), + 'units' => $units }); + } + +# User units share one tab because the owner column distinguishes accounts. +my @visible_user_units; +foreach my $u (@$user_units) { + next if (!unit_visible_on_index($u)); + my ($display, $type) = index_name_type($u->{'name'}); + $u->{'_display'} = $config{'show_unit_suffixes'} ? + $u->{'name'} : $display; + $u->{'_type'} = $type || 'service'; + push(@visible_user_units, $u); + } +$user_units = \@visible_user_units; +if (tab_visible('user') && + systemd_can_view_user_scope(\%access, $unituser)) { + push(@tabs, { 'id' => 'user', + 'user' => 1, + 'unituser' => $unituser, + 'title' => $text{'systemd_tab_user'}, + 'desc' => $text{'systemd_tabdesc_user'}, + 'units' => $user_units }); + } +return @tabs; +} + +# print_index_tab(tab, form-number) +# Outputs one tab description and its mass-action table. +sub print_index_tab +{ +my ($tab, $formno) = @_; +my $user_tab = $tab->{'user'} ? 1 : 0; +my $can_status = systemd_can_inspect(\%access, $user_tab, $tab->{'unituser'}); +my $can_logs = systemd_can_logs(\%access, $user_tab, $tab->{'unituser'}); +my $can_start = systemd_can_runtime(\%access, 'start', + $user_tab, $tab->{'unituser'}); +my $can_stop = systemd_can_runtime(\%access, 'stop', + $user_tab, $tab->{'unituser'}); +my $can_restart = systemd_can_runtime(\%access, 'restart', + $user_tab, $tab->{'unituser'}); +my $can_boot = systemd_can_boot(\%access, $user_tab, $tab->{'unituser'}); +my $can_mask = $user_tab ? 0 : + systemd_can_mask(\%access, $user_tab, $tab->{'unituser'}); +my $can_delete = $user_tab ? + systemd_can_delete(\%access, $user_tab, $tab->{'unituser'}) : 0; +my $selectable = exists($tab->{'selectable'}) ? $tab->{'selectable'} : 1; +$selectable &&= $can_status || $can_logs || $can_start || $can_stop || + $can_restart || $can_boot || $can_mask || $can_delete ? 1 : 0; +my $show_unit_state = exists($tab->{'show_unit_state'}) ? + $tab->{'show_unit_state'} : 1; +my $show_type = !$config{'show_unit_suffixes'} && + ($user_tab || $tab->{'show_type'}); +my %linger_cache; + +# The create link inherits tab context so the create form opens with the right +# unit type or user-unit mode selected. +my $create_type = $user_tab ? 'service' : $tab->{'create'}; +my $create_link = index_create_link($tab, $user_tab, $create_type); +my @links = $selectable ? + ( select_all_link("d", $formno), + select_invert_link("d", $formno) ) : + ( ); +push(@links, $create_link) if ($create_link); + +print ui_div($tab->{'desc'}); +if (!@{$tab->{'units'}}) { + print ui_tag('p', index_empty_message($tab)); + print ui_links_row([ $create_link ]) if ($create_link); + return 0; + } + +# Start the mass-action form and keep scope in a hidden field for user units. +print ui_form_start("mass_units.cgi", "post"); +print ui_links_row(\@links) if (@links); +print ui_hidden("scope", "users") if ($user_tab); + +# Mixed-type tabs only need a type column when unit suffixes are hidden. +my @heads = $selectable ? ( "" ) : ( ); +push(@heads, $text{'systemd_name'}); +push(@heads, $text{'systemd_desc'}) if ($config{'desc'}); +push(@heads, $text{'systemd_type'}) if ($show_type); +push(@heads, $text{'systemd_unit_state'}) if ($show_unit_state); +push(@heads, $text{'systemd_runtime_state'}); +push(@heads, $text{'systemd_owner'}, $text{'systemd_linger_status'}) + if ($user_tab); +print ui_columns_start(\@heads); +foreach my $u (@{$tab->{'units'}}) { + # Generated units without real files and masked units are shown read-only. + my $editable = $u->{'file'} && -f $u->{'file'} ? 1 : 0; + my $link = index_edit_url($u, $user_tab); + my $title = (!$editable || + (defined($u->{'boot'}) && $u->{'boot'} == -1) ? + html_escape($u->{'_display'}) : + ui_link($link, html_escape($u->{'_display'}))); + my $checkvalue = $user_tab ? + user_unit_selection_value($u->{'user'}, $u->{'name'}) : + $u->{'name'}; + + # Build the row from common columns first, then append user-only columns. + my @row = $selectable ? + ( ui_checkbox("d", $checkvalue, undef) ) : + ( ); + push(@row, $title); + push(@row, html_escape($u->{'desc'})) if ($config{'desc'}); + push(@row, html_escape(index_unit_type_title($u->{'_type'}))) + if ($show_type); + push(@row, index_unit_state_column($u->{'unitstate'})) + if ($show_unit_state); + push(@row, index_runtime_state_column( + $u->{'runtime'}, $u->{'substate'})); + if ($user_tab) { + # Linger is per user, so cache it instead of calling loginctl per row. + if (!exists($linger_cache{$u->{'user'}})) { + $linger_cache{$u->{'user'}} = + user_linger_enabled($u->{'user'}); + } + my $linger_html = systemd_can_linger(\%access, $u->{'user'}) ? + linger_toggle_link( + $u->{'user'}, $linger_cache{$u->{'user'}}) : + html_escape($linger_cache{$u->{'user'}} ? + $text{'yes'} : $text{'no'}); + push(@row, ui_tag('tt', html_escape($u->{'user'})), + $linger_html); + } + print ui_columns_row(\@row); + } + +# Repeat row-selection links below the table for long lists. +print ui_columns_end(); +if ($selectable) { + print ui_links_row(\@links); + my @runtime_buttons; + push(@runtime_buttons, [ "start", $text{'index_start'} ]) + if ($can_start); + push(@runtime_buttons, [ "stop", $text{'index_stop'} ]) + if ($can_stop); + push(@runtime_buttons, [ "restart", $text{'index_restart'} ]) + if ($can_restart); + my @boot_buttons = $can_boot ? + ( [ "addboot", $text{'index_addboot'} ], + [ "delboot", $text{'index_delboot'} ] ) : ( ); + my @mask_buttons = $can_mask ? + ( [ "mask", $text{'index_mask'} ], + [ "unmask", $text{'index_unmask'} ] ) : ( ); + my @inspect_buttons; + push(@inspect_buttons, [ "status", $text{'index_statusnow'} ]) + if ($can_status); + push(@inspect_buttons, [ "logs", $text{'index_logsnow'} ]) + if ($can_logs); + my @delete_buttons = $can_delete ? + ( [ "delete", $text{'index_delete'} ] ) : ( ); + my @action_groups = $tab->{'inspect_only'} ? + grep { @$_ } ( \@inspect_buttons ) : + grep { @$_ } ( \@runtime_buttons, \@boot_buttons, + \@mask_buttons, \@inspect_buttons ); + print ui_form_grouped_buttons([ [ @action_groups ], + [ \@delete_buttons ] ]) + if (@action_groups || @delete_buttons); + } +print ui_form_end(); +return 1; +} + +# index_name_type(unit-name) +# Splits a full unit name into display name and unit type. +sub index_name_type +{ +my ($name) = @_; +my $units_piped = join('|', map { quotemeta } get_unit_types()); + +# Only strip suffixes that systemd understands as unit types. +my ($type) = $name =~ /\.([^.]+)$/; +if (defined($type) && $type =~ /^(?:$units_piped)$/) { + my $display = $name; + $display =~ s/\.$type$//; + return ($display, $type); + } +return ($name, ""); +} + +# index_tab_title(type) +# Returns the plural tab title for a systemd unit type. +sub index_tab_title +{ +my ($type) = @_; +return $text{'systemd_tab_'.$type} || + $text{'systemd_type_'.$type} || + ucfirst($type); +} + +# index_tab_desc(type) +# Returns the explanatory text shown under a systemd unit tab. +sub index_tab_desc +{ +my ($type) = @_; +return $text{'systemd_tabdesc_'.$type} || ""; +} + +# index_create_label(tab-id, create-type) +# Returns the tab-specific label for the create-unit link. +sub index_create_label +{ +my ($tabid, $type) = @_; +return $text{'index_sadd_'.$tabid} || + $text{'index_sadd_'.$type} || + $text{'index_sadd'}; +} + +# index_create_link(tab, user-tab, create-type) +# Returns the create link for a tab, if allowed. +sub index_create_link +{ +my ($tab, $user_tab, $create_type) = @_; +return "" if (!$create_type); +return "" if (!systemd_can_create(\%access, $user_tab, $tab->{'unituser'})); +my $create_url = $user_tab && $tab->{'unituser'} ? + "edit_unit.cgi?new=1&scope=user&unittype=service&unituser=". + urlize($tab->{'unituser'}) : + $user_tab ? "edit_unit.cgi?new=1&scope=user&unittype=service" : + "edit_unit.cgi?new=1&unittype=".urlize($create_type); +return ui_link($create_url, + index_create_label($tab->{'id'}, $create_type)); +} + +# index_empty_message(tab) +# Returns the empty-state message for a unit tab. +sub index_empty_message +{ +my ($tab) = @_; +if ($tab->{'user'}) { + return text('index_empty_user_owner', + ui_tag('tt', html_escape($tab->{'unituser'}))) + if ($tab->{'unituser'}); + return $text{'index_empty_user'}; + } +return $text{'index_empty_'.$tab->{'id'}} || $text{'index_empty_units'}; +} + +# index_unit_type_title(type) +# Returns the display label for a single unit type. +sub index_unit_type_title +{ +my ($type) = @_; +return $text{'systemd_type_'.$type} || $type; +} + +# user_unit_selection_value(user, unit) +# Encodes a user-unit owner and name into one checkbox value for mass actions. +sub user_unit_selection_value +{ +my ($user, $unit) = @_; +return urlize($user)."\t".urlize($unit); +} + +# linger_toggle_link(user, enabled) +# Returns a link to toggle linger for a user-unit owner. +sub linger_toggle_link +{ +my ($user, $enabled) = @_; + +# The link flips the current state and lets set_linger.cgi validate again. +my $target = $enabled ? 0 : 1; +my $label = $enabled ? $text{'yes'} : $text{'no'}; +my $type = $enabled ? 'success' : 'warn'; +my $title = $enabled ? text('systemd_linger_disable', $user) : + text('systemd_linger_enable', $user); +my $url = "set_linger.cgi?user=".urlize($user)."&enabled=".$target; +return ui_tag('a', ui_text_color(html_escape($label), $type), + { 'href' => $url, + 'class' => 'systemd_linger_toggle', + 'title' => $title }); +} + +# index_unit_state_column(state) +# Returns a formatted UnitFileState value for a unit row. +sub index_unit_state_column +{ +my ($state) = @_; +return index_state_column($state, { + 'enabled' => 'success', + 'enabled-runtime' => 'success', + 'disabled' => 'warn', + 'masked' => 'danger', + 'masked-runtime' => 'danger', + 'bad' => 'danger', + }); +} + +# index_runtime_state_column(state, substate) +# Returns a formatted ActiveState value, with SubState when systemd reports it. +sub index_runtime_state_column +{ +my ($state, $substate) = @_; +return index_state_column($state, { + 'active' => 'success', + 'inactive' => 'warn', + 'activating' => 'warn', + 'deactivating' => 'warn', + 'failed' => 'danger', + }, $substate); +} + +# index_state_column(state, colors, [substate]) +# Returns a displayed systemd state value with light semantic coloring. +sub index_state_column +{ +my ($state, $colors, $substate) = @_; +return ui_tag('i', $text{'index_unknown'}) + if (!defined($state) || $state eq ""); +my $label = ucfirst($state); +$label .= " (".$substate.")" + if (defined($substate) && $substate ne "" && $substate ne $state); +my $safe = html_escape($label); +my $color = $colors->{$state}; +return $color ? ui_text_color($safe, $color) : $safe; +} + +# index_edit_url(unit, user-tab) +# Returns the edit URL for a unit, preferring its safe override file. +sub index_edit_url +{ +my ($unit, $user_tab) = @_; +my $url = $user_tab ? + "edit_unit.cgi?scope=user&unituser=".urlize($unit->{'user'}). + "&name=".urlize($unit->{'name'}) : + "edit_unit.cgi?name=".urlize($unit->{'name'}); +if (dropin_exists($user_tab, $unit->{'user'}, $unit->{'name'})) { + $url .= "&dropin=1"; + } +return $url; +} diff --git a/systemd/install_check.pl b/systemd/install_check.pl new file mode 100644 index 000000000..695065afb --- /dev/null +++ b/systemd/install_check.pl @@ -0,0 +1,19 @@ +#!/usr/local/bin/perl +# Decides whether the standalone systemd module should be shown. + +use strict; +use warnings; +use lib ".."; + +use WebminCore; + +# is_installed(mode) +# Returns Webmin's install-check code for systems with systemctl available. +sub is_installed +{ +# Mode 0 is a boolean probe; mode 1 asks whether the module should be visible. +return 0 if (!has_command("systemctl")); +return $_[0] ? 2 : 1; +} + +1; diff --git a/systemd/lang/en b/systemd/lang/en new file mode 100644 index 000000000..36c768f5f --- /dev/null +++ b/systemd/lang/en @@ -0,0 +1,400 @@ +index_title=Systemd Services and Units +index=Module Index +index_return=systemd services and units +index_stop=Stop +index_start=Start +index_restart=Restart +index_addboot=Enable +index_delboot=Disable +index_mask=Mask +index_unmask=Unmask +index_delete=Delete +index_statusnow=Status +index_logsnow=Logs +index_unknown=Unknown +index_sadd=Create a new systemd unit +index_sadd_service=Create a new service unit +index_sadd_timer=Create a new timer unit +index_sadd_socket=Create a new socket unit +index_sadd_path=Create a new path unit +index_sadd_target=Create a new target unit +index_sadd_storage=Create a new storage unit +index_sadd_resources=Create a new slice unit +index_sadd_user=Create a new user unit +index_empty_units=No units were found in this section. +index_empty_service=No service units were found. +index_empty_timer=No timer units were found. +index_empty_socket=No socket units were found. +index_empty_path=No path units were found. +index_empty_target=No target units were found. +index_empty_storage=No mount, automount or swap units were found. +index_empty_resources=No slice or scope units were found. +index_empty_device=No device units were found. +index_empty_user=No user units were found. +index_empty_user_owner=No user units were found for $1. +index_edit_files=Edit Unit Files +index_edit_filesdesc=Inspect or manually edit discovered system and user unit files from systemd unit directories. +index_dropins=View Drop-in Overrides +index_dropinsdesc=Inspect discovered system and user drop-in override files and open safe drop-ins for editing. +index_reload=Reload Daemon +index_reloaddesc=Run systemctl daemon-reload so the system manager re-reads changed unit files. +index_reload_user=Reload User Manager +index_reload_userdesc=Run systemctl --user daemon-reload so the user's systemd manager re-reads changed unit files. +edit_ecannot=You are not allowed to access systemd units +edit_startnow=Start +edit_stopnow=Stop +edit_restartnow=Restart +edit_statusnow=Status +edit_propsnow=Properties +edit_depsnow=Dependencies +edit_logsnow=Logs +edit_overridenow=Create Override +edit_editoverridenow=Edit Override +edit_stockunitnow=Edit Base Unit +edit_deleteoverridenow=Delete Override +ss_ecannot=You are not allowed to start or stop systemd units +acl_section_users=User unit owner restrictions +acl_users=Can manage user units for +acl_all=All users +acl_this=Current Webmin user +acl_only=Only users +acl_except=All except users +acl_uid=Users with UID in range +acl_gid=Users with primary group +acl_section_view=Read access +acl_section_runtime=Runtime control +acl_section_change=Change and advanced access +acl_view=View system units +acl_view_user=View user units +acl_status=View system unit status, properties and dependencies +acl_status_user=View user unit status, properties and dependencies +acl_logs=View system unit logs +acl_logs_user=View user unit logs +acl_start=Start system units +acl_start_user=Start user units +acl_stop=Stop system units +acl_stop_user=Stop user units +acl_restart=Restart system units +acl_restart_user=Restart user units +acl_boot=Enable or disable system units at boot +acl_boot_user=Enable or disable user units at boot +acl_mask=Mask or unmask system units +acl_mask_user=Mask or unmask user units +acl_reload=Reload the systemd manager +acl_linger=Manage user linger state +acl_create=Create system units +acl_create_user=Create user units +acl_edit=Edit system unit files +acl_edit_user=Edit user unit files +acl_delete=Delete system units +acl_delete_user=Delete user units +acl_dropin=Manage system unit drop-ins +acl_dropin_user=Manage user unit drop-ins +acl_manual=Manually edit discovered system unit files +acl_manual_user=Manually edit discovered user unit files +acl_backup=Include systemd unit files in backups +eacl_np=Access denied: +eacl_penter=Access to the systemd module is not permitted. +eacl_pview=Viewing system units is not permitted. +eacl_pview_user=Viewing systemd user units for this user is not permitted. +eacl_pstatus=Viewing status, properties or dependencies is not permitted. +eacl_plogs=Viewing systemd unit logs is not permitted. +eacl_pstart=Starting systemd units is not permitted. +eacl_pstop=Stopping systemd units is not permitted. +eacl_prestart=Restarting systemd units is not permitted. +eacl_pboot=Changing unit startup state is not permitted. +eacl_pmask=Masking or unmasking systemd units is not permitted. +eacl_pcreate=Creating system units is not permitted. +eacl_pcreate_user=Creating user units for this user is not permitted. +eacl_pedit=Editing system unit files is not permitted. +eacl_pedit_user=Editing user unit files for this user is not permitted. +eacl_pdelete=Deleting system units is not permitted. +eacl_pdelete_user=Deleting user units for this user is not permitted. +eacl_pdropin=Managing system unit drop-ins is not permitted. +eacl_pdropin_user=Managing user unit drop-ins for this user is not permitted. +eacl_pmanual=Manually editing system unit files is not permitted. +eacl_pmanual_user=Manually editing user unit files for this user is not permitted. +eacl_preload=Reloading the systemd manager is not permitted. +eacl_plinger=Managing linger for this user is not permitted. +eacl_pbackup=Backing up systemd unit files is not permitted. +log_modify=Modified unit $1 +log_create=Created unit $1 +log_delete=Deleted unit $1 +log_override=Created drop-in override for unit $1 +log_deleteoverride=Deleted drop-in override for unit $1 +log_status=Fetched status of unit $1 +log_props=Fetched properties of unit $1 +log_deps=Listed dependencies of unit $1 +log_logs=Fetched logs for unit $1 +log_massstart=Started units $1 +log_massstop=Stopped units $1 +log_massrestart=Restarted units $1 +log_massenable=Enabled units $1 +log_massdisable=Disabled units $1 +log_massmask=Masked units $1 +log_massunmask=Unmasked units $1 +log_user_modify=Modified user unit $1 for $2 +log_user_create=Created user unit $1 for $2 +log_user_delete=Deleted user unit $1 for $2 +log_user_override=Created drop-in override for user unit $1 for $2 +log_user_deleteoverride=Deleted drop-in override for user unit $1 for $2 +log_user_status=Fetched status of user unit $1 for $2 +log_user_props=Fetched properties of user unit $1 for $2 +log_user_deps=Listed dependencies of user unit $1 for $2 +log_user_logs=Fetched logs for user unit $1 for $2 +log_user_massstart=Started user units $1 for $2 +log_user_massstop=Stopped user units $1 for $2 +log_user_massrestart=Restarted user units $1 for $2 +log_user_massenable=Enabled user units $1 for $2 +log_user_massdisable=Disabled user units $1 for $2 +log_user_massmask=Masked user units $1 for $2 +log_user_massunmask=Unmasked user units $1 for $2 +log_user_massdelete=Deleted user units $1 for $2 +log_user_linger=Set linger for user $1 to $2 +log_manual=Manually edited unit file $1 +log_reload=Reloaded systemd daemon +log_user_reload=Reloaded systemd user manager for $1 +log_user_manual=Manually edited user unit file $1 for $2 +mass_enone=No units selected +mass_enoallow=cannot stop $1 +mass_failed=.. failed +mass_skipped=.. skipped +mass_ok=.. done +mass_ustart=Starting Units +mass_urestart=Restarting Units +mass_ustop=Stopping Units +mass_usenable=Enabling Units +mass_usdisable=Disabling Units +mass_umask=Masking Units +mass_uunmask=Unmasking Units +mass_udelete=Deleting User Units +mass_uenable=Enabling unit $1. +mass_udisable=Disabling unit $1. +mass_umasking=Masking unit $1 .. +mass_uunmasking=Unmasking unit $1 .. +mass_udeleting=Deleting unit $1 .. +mass_ustarting=Starting unit $1 .. +mass_ustopping=Stopping unit $1 .. +mass_urestarting=Restarting unit $1 .. +mass_edelete_user=Mass delete is only available for user units +mass_enostart=Start is not applicable for this unit type +mass_enorestart=Restart is not applicable for this unit type +manual_title=Edit Systemd Unit Files +manual_desc=This editor is limited to discovered system unit files and drop-in override files. Packaged files under vendor directories are shown for inspection and advanced edits; prefer local overrides for normal customization. +manual_desc_user=This editor is limited to discovered user unit files and drop-in override files owned by $1. Changes affect that user's systemd manager; save, then reload the user manager from the user units page. +manual_select=Editing config file +manual_ok=Show +manual_edit_err=Failed to edit systemd unit files +manual_err=Failed to save systemd unit file +manual_enone=No editable systemd unit files were found. Create a unit first, or enable manual editing for an existing local unit file. +manual_enone_user=No editable user unit files were found for $1. Create a user unit first, or return to the user units page. +manual_efile=Missing or invalid systemd unit file +manual_eread=Failed to read systemd unit file +manual_ewrite=Failed to write systemd unit file +dropins_title=Drop-in Override Files +dropins_desc=This inventory lists discovered drop-in override files under local system and user unit directories. Drop-ins for known units can be opened for editing when permitted by ACLs. +dropins_empty=No drop-in override files were found. +dropins_disabled=The drop-in override inventory is disabled in the module configuration. +dropins_scope=Scope +dropins_scope_system=System +dropins_scope_user=User +dropins_file=Drop-in file +dropins_actions=Actions +dropins_edit=Edit +dropins_not_editable=Not editable +dropins_view_only=View only +dropins_unit_missing=Unit not found +reload_title=Reloading Systemd Daemon +reload_doing=Reloading the systemd daemon .. +reload_err=Failed to reload systemd daemon +reload_user_title=Reloading Systemd User Manager +reload_user_doing=Reloading systemd user manager for $1 .. +reload_user_err=Failed to reload systemd user manager +mode_systemd=Systemd +systemd_title1=Create Systemd Unit +systemd_title2=Edit Systemd Unit +systemd_title2_user=Edit Systemd User Unit +systemd_title2_view=View Systemd Unit +systemd_title2_view_user=View Systemd User Unit +systemd_egone=Unit no longer exists! +systemd_header=Systemd unit details +systemd_name=Unit name +systemd_type=Unit type +systemd_file=Configuration file +systemd_type_service=Service +systemd_type_timer=Timer +systemd_type_socket=Socket +systemd_type_path=Path +systemd_type_target=Target +systemd_type_mount=Mount +systemd_type_automount=Automount +systemd_type_swap=Swap +systemd_type_slice=Slice +systemd_type_scope=Scope +systemd_type_device=Device +systemd_desc=Unit description +systemd_start=Commands to run on startup +systemd_stop=Commands to run on shutdown +systemd_unitconf=Type-specific settings +systemd_timeroncalendar=Calendar schedule +systemd_timeronbootsec=Delay after boot +systemd_timeronunitactivesec=Delay after activation +systemd_timerpersistent=Catch up missed runs? +systemd_timerrandomizeddelaysec=Randomized delay +systemd_timeraccuracysec=Timer accuracy +systemd_timerunit=Unit to activate +systemd_socketlistenstream=Stream listener +systemd_socketlistendatagram=Datagram listener +systemd_socketlistenfifo=FIFO listener +systemd_socketaccept=Accept each connection? +systemd_socketuser=Socket owner +systemd_socketgroup=Socket group +systemd_socketmode=Socket mode +systemd_socketservice=Service to activate +systemd_pathexists=Path exists +systemd_pathexistsglob=Path exists glob +systemd_pathchanged=Path changed +systemd_pathmodified=Path modified +systemd_pathdirectorynotempty=Directory not empty +systemd_pathmakedirectory=Create watched directory? +systemd_pathunit=Unit to activate +systemd_mountwhat=Mount source +systemd_mountwhere=Mount point +systemd_mounttype=Filesystem type +systemd_mountoptions=Mount options +systemd_automountmount=Existing mount unit +systemd_automountmount_none=Select manually +systemd_automountwhere=Automount path +systemd_automountidle=Idle timeout +systemd_automountmode=Directory mode +systemd_swapwhat=Swap device or file +systemd_swappriority=Swap priority +systemd_swapoptions=Swap options +systemd_swaptimeoutsec=Swap timeout +systemd_slicecpuweight=CPU weight +systemd_slicememorymax=Memory maximum +systemd_slicetasksmax=Tasks maximum +systemd_sliceioweight=I/O weight +systemd_conf=Systemd unit configuration +systemd_boot=Start at boot time? +systemd_status=Status details +systemd_unit_state=Unit file state +systemd_runtime_state=Runtime state +systemd_main_pid=Main PID +systemd_err=Failed to save systemd unit +systemd_linger_err=Failed to change systemd user linger setting +systemd_ename=Missing or invalid systemd unit name +systemd_eunittype=Missing, invalid or mismatched systemd unit type +systemd_eclash=A unit with the same name already exists +systemd_edesc=Missing unit description +systemd_eunitconf=Missing type-specific unit settings +systemd_eunitconfsection=Type-specific settings must contain directives only, without section headers +systemd_etimertrigger=Enter at least one timer trigger +systemd_etimerunit=Missing or invalid unit to activate +systemd_esocketlisten=Enter at least one socket listener +systemd_esocketmode=Invalid socket mode +systemd_esocketservice=Missing or invalid service to activate +systemd_epathtrigger=Enter at least one watched path +systemd_epathunit=Missing or invalid unit to activate +systemd_emountwhat=Missing mount source +systemd_emountname=Mount unit name must match the mount point +systemd_eautomountmount=Missing or invalid matching .mount unit +systemd_eautomountname=Automount unit name must match the mount point +systemd_eautomountmode=Invalid automount directory mode +systemd_eswapwhat=Missing or invalid swap device or file +systemd_eswappriority=Invalid swap priority +systemd_esliceweight=Invalid systemd resource weight +systemd_eslicelimit=Invalid systemd resource limit +systemd_return=systemd unit +systemd_econf=No systemd unit configuration entered +systemd_estart=Missing commands to run on startup +systemd_euser=Missing or invalid user for systemd user unit +systemd_euserhome=Failed to create systemd user unit directory +systemd_euserunitfile=Unsafe or invalid systemd user unit file +systemd_euserunitdir=Unsafe or wrongly-owned systemd user unit directory +systemd_edropinfile=Unsafe or invalid systemd drop-in override file +systemd_ereadonly=This systemd unit file is managed at runtime and cannot be edited directly. +systemd_eloginctl=The loginctl command is not available on your system +systemd_esystemctl=The systemctl command is not available on your system +systemd_eusercmd=Failed to run systemctl --user for $1: $2 +systemd_elinger=Missing or invalid linger state +systemd_everify=Failed to verify systemd unit file: $1 +systemd_userservice=Create as user unit? +systemd_unituser=User service owner +systemd_linger=Enable linger for this user? +systemd_linger_user=Allow user units to run without login? +systemd_owner=User +systemd_linger_status=Linger +systemd_linger_enable=Enable linger for $1 +systemd_linger_disable=Disable linger for $1 +systemd_unit_for_user=$1 for user $2 +systemd_tab_service=Services +systemd_tab_timer=Timers +systemd_tab_socket=Sockets +systemd_tab_path=Paths +systemd_tab_target=Targets +systemd_tab_storage=Storage +systemd_tab_resources=Resources +systemd_tab_device=Devices +systemd_tab_user=User units +systemd_tabdesc_service=Services run long-lived, short-lived or one-shot commands under the system-level systemd manager, with dependencies, restart policy, logging and enablement handled by the unit file. +systemd_tabdesc_timer=Timers activate services or other units from calendar schedules, monotonic intervals or boot-relative delays, and are useful for dependency-aware scheduled jobs. +systemd_tabdesc_socket=Sockets listen on network, filesystem or IPC endpoints and activate matching services only when traffic arrives, keeping idle services stopped until needed. +systemd_tabdesc_path=Paths watch files or directories and activate matching units when paths appear, change, become non-empty or meet other filesystem conditions. +systemd_tabdesc_target=Targets collect related units into named synchronization points such as boot states, maintenance modes and dependency groups. +systemd_tabdesc_storage=Mount, automount and swap units define filesystems, on-demand mounts and swap resources that systemd can order, activate and track. +systemd_tabdesc_resources=Slice and scope units show systemd resource-control groups. Slices define persistent cgroup policy; scopes usually represent externally-created process groups. +systemd_tabdesc_device=Device units represent kernel devices exposed to systemd by udev. They are runtime objects for dependency tracking and inspection. +systemd_tabdesc_user=User units live under users' home directories and are controlled by each user's systemd manager. Linger controls whether a user's manager can run without an active login. +systemd_evisibletabs=At least one tab must be selected to show on the index page +systemd_statustitle=Unit Status +systemd_doingstatus=Requesting status of unit $1 .. +systemd_props=Unit Properties +systemd_doingprops=Reading properties for unit $1 .. +systemd_deps=Unit Dependencies +systemd_doingdeps=Listing dependencies for unit $1 .. +systemd_logs=Unit Logs +systemd_doinglogs=Reading logs for unit $1 .. +systemd_ejournal=The journalctl command is not available on your system +systemd_eduration=Missing or invalid time for $1 +systemd_epath=Missing or invalid path for $1 +systemd_elimitnofile=Missing or invalid open files limit +systemd_eoutput=Missing or invalid setting for $1 +systemd_eprotectsystem=Invalid protect system setting +systemd_ereadwritepath=Invalid writable path $1 +systemd_advanced=Advanced options +systemd_startpre=Commands to run before startup +systemd_startpost=Commands to run after startup +systemd_stoppost=Commands to run after shutdown +systemd_reload=Commands to run on reload +systemd_before=Start before units +systemd_after=Start after units +systemd_wants=Wants units +systemd_requires=Requires units +systemd_conflicts=Conflicts with units +systemd_onfailure=On failure units +systemd_onsuccess=On success units +systemd_servicetype=Service type +systemd_remain=Remain active after command exits? +systemd_pidfile=PID file +systemd_env=Environment variables +systemd_envfile=Environment file +systemd_user=Run as user +systemd_group=Run as group +systemd_killmode=Kill mode +systemd_workdir=Working directory +systemd_restart=Restart policy +systemd_restartsec=Restart delay +systemd_watchdogsec=Watchdog timeout +systemd_timeout=Startup timeout +systemd_timeoutstop=Shutdown timeout +systemd_limitnofile=Open files limit +systemd_logstd=Standard output +systemd_logerr=Standard error +systemd_syslogid=Log identifier +systemd_nonewprivs=Prevent gaining new privileges? +systemd_privatetmp=Use private temporary directory? +systemd_protectsystem=Protect system files +systemd_readwritepaths=Writable paths +systemd_wantedby=Install target +syslog_journalctl=Systemd journal diff --git a/systemd/log_parser.pl b/systemd/log_parser.pl new file mode 100755 index 000000000..143fa80d9 --- /dev/null +++ b/systemd/log_parser.pl @@ -0,0 +1,100 @@ +# log_parser.pl +# Functions for parsing this module's logs + +use strict; +use warnings; + +require 'systemd-lib.pl'; ## no critic + +our %text; + +# parse_webmin_log(user, script, action, type, object, params) +# Converts logged information from this module into escaped HTML fragments. +sub parse_webmin_log +{ +my ($user, $script, $action, $type, $object, $p) = @_; + +# This parser returns HTML fragments, so escape log values before wrapping them +# in UI tags or translated strings. +if ($type eq 'systemd-user' && + ($action eq 'modify' || $action eq 'create' || $action eq 'delete' || + $action eq 'override' || $action eq 'deleteoverride' || + $action eq 'status' || $action eq 'props' || $action eq 'deps' || + $action eq 'logs' || + $action eq 'massstart' || $action eq 'massstop' || + $action eq 'massrestart' || $action eq 'massenable' || + $action eq 'massdisable' || $action eq 'massmask' || + $action eq 'massunmask' || $action eq 'massdelete' || + $action eq 'linger' || + $action eq 'manual' || $action eq 'reload')) { + + # Linger logs describe the user rather than one or more unit names. + if ($action eq 'linger') { + return text('log_user_linger', + ui_tag('tt', html_escape($p->{'user'})), + $p->{'enabled'} ? $text{'yes'} : $text{'no'}); + } + if ($action eq 'manual') { + return text('log_user_manual', + ui_tag('tt', html_escape($object)), + ui_tag('tt', html_escape($p->{'user'}))); + } + if ($action eq 'reload') { + return text('log_user_reload', + ui_tag('tt', html_escape($p->{'user'}))); + } + + # User-unit actions include both escaped unit names and the owner. + return text('log_user_'.$action, + join(", ", map { ui_tag('tt', html_escape($_)) } + split(/\s+/, $object)), + ui_tag('tt', html_escape($p->{'user'}))); + } + +# System-unit messages use the same escaping as user units because unit names +# can be shown directly in the Webmin log viewer. +elsif ($type eq 'systemd' && $action eq 'modify') { + return text('log_modify', ui_tag('tt', html_escape($object))); + } +elsif ($type eq 'systemd' && $action eq 'create') { + return text('log_create', ui_tag('tt', html_escape($object))); + } +elsif ($type eq 'systemd' && $action eq 'delete') { + return text('log_delete', ui_tag('tt', html_escape($object))); + } +elsif ($type eq 'systemd' && $action eq 'override') { + return text('log_override', ui_tag('tt', html_escape($object))); + } +elsif ($type eq 'systemd' && $action eq 'deleteoverride') { + return text('log_deleteoverride', + ui_tag('tt', html_escape($object))); + } +elsif ($type eq 'systemd' && $action eq 'manual') { + return text('log_manual', ui_tag('tt', html_escape($object))); + } +elsif ($type eq 'systemd' && $action eq 'reload') { + return $text{'log_reload'}; + } +elsif ($type eq 'systemd' && + ($action eq 'status' || $action eq 'props' || $action eq 'deps' || + $action eq 'logs')) { + return text('log_'.$action, + join(", ", map { ui_tag('tt', html_escape($_)) } + split(/\s+/, $object))); + } + +# Mass-action logs contain space-separated unit names. +elsif ($type eq 'systemd' && + ($action eq 'massstart' || $action eq 'massstop' || + $action eq 'massrestart' || + $action eq 'massenable' || $action eq 'massdisable' || + $action eq 'massmask' || $action eq 'massunmask')) { + return text('log_'.$action, + join(", ", map { ui_tag('tt', html_escape($_)) } + split(/\s+/, $object))); + } +else { + # Unknown log records fall back to Webmin's default rendering. + return; + } +} diff --git a/systemd/mass_units.cgi b/systemd/mass_units.cgi new file mode 100755 index 000000000..0919555aa --- /dev/null +++ b/systemd/mass_units.cgi @@ -0,0 +1,458 @@ +#!/usr/local/bin/perl +# Start, stop, inspect or enable a set of systemd units + +use strict; +use warnings; + +require './systemd-lib.pl'; ## no critic + +our (%access, %in, %text); + +# Mass actions are POSTed from the index table or redirected from edit_unit.cgi. +ReadParse(); +my @sel = split(/\0/, $in{'d'}); +@sel || error($text{'mass_enone'}); + +# Work out whether selections target system or user managers. +my $user_scope = $in{'scope'} eq 'user' ? 1 : 0; +my $users_scope = $in{'scope'} eq 'users' ? 1 : 0; +my $unituser = clean_unit_value($in{'unituser'}); +if ($user_scope) { + get_user_details($unituser) || + error($text{'systemd_euser'}); + } +if ($in{'return'}) { + valid_unit_name($in{'return'}) || + error($text{'systemd_ename'}); + } + +# Convert raw checkbox values into validated action records. +my @units = mass_units(\@sel, $user_scope, $users_scope, $unituser); +foreach my $u (@units) { + systemd_can_view_scope(\%access, $u->{'user_scope'}, $u->{'user'}) || + systemd_acl_error($u->{'user_scope'} ? 'pview_user' : 'pview'); + } + +# Convert submitted buttons into action flags. +my $start = $in{'start'} ? 1 : 0; +my $stop = $in{'stop'} ? 1 : 0; +my $restart = $in{'restart'} ? 1 : 0; +my $status = $in{'status'} ? 1 : 0; +my $props = $in{'props'} ? 1 : 0; +my $deps = $in{'deps'} ? 1 : 0; +my $logs = $in{'logs'} ? 1 : 0; +my $enable = $in{'addboot'} ? 1 : 0; +my $disable = $in{'delboot'} ? 1 : 0; +my $mask = $in{'mask'} ? 1 : 0; +my $unmask = $in{'unmask'} ? 1 : 0; +my $delete = $in{'delete'} ? 1 : 0; +my $printed_action_result = 0; + +# Use an unbuffered page because long-running systemctl operations should show +# progress as each unit completes. +ui_print_unbuffered_header(undef, $logs ? $text{'systemd_logs'} : + $deps ? $text{'systemd_deps'} : + $props ? $text{'systemd_props'} : + $status ? $text{'systemd_statustitle'} : + $restart ? $text{'mass_urestart'} : + $start ? $text{'mass_ustart'} : + $stop ? $text{'mass_ustop'} : + $enable ? $text{'mass_usenable'} : + $disable ? $text{'mass_usdisable'} : + $mask ? $text{'mass_umask'} : + $unmask ? $text{'mass_uunmask'} : + $delete ? $text{'mass_udelete'} : + $text{'mass_ustop'}, ""); + +# Get status +if ($status) { + # Show full systemd status output for selected units. + foreach my $u (@units) { + systemd_can_inspect(\%access, $u->{'user_scope'}, $u->{'user'}) || + systemd_acl_error('pstatus'); + my $s = $u->{'name'}; + + # Status command failures can still return useful output. + print_action_start(text('systemd_doingstatus', + mass_unit_label($u))); + my ($ok, $out) = $u->{'user_scope'} ? + status_user_unit($u->{'user'}, $s) : + status_unit($s); + print ui_tag('pre', html_escape($out)) if ($out); + print $text{'mass_failed'}, ui_p() if (!$out); + } + mass_log('status', \@units); + } + +# Get properties +if ($props) { + # Show the exact property set systemd reports for selected units. + foreach my $u (@units) { + systemd_can_inspect(\%access, $u->{'user_scope'}, $u->{'user'}) || + systemd_acl_error('pstatus'); + my $s = $u->{'name'}; + + # Properties are read from the selected system or user manager. + print_action_start(text('systemd_doingprops', + mass_unit_label($u))); + my ($ok, $out) = $u->{'user_scope'} ? + properties_user_unit($u->{'user'}, $s) : + properties_unit($s); + print ui_tag('pre', html_escape($out)) if ($out); + print $text{'mass_failed'}, ui_p() if (!$ok && !$out); + } + mass_log('props', \@units); + } + +# Get dependencies +if ($deps) { + # Show the dependency tree for selected units. + foreach my $u (@units) { + systemd_can_inspect(\%access, $u->{'user_scope'}, $u->{'user'}) || + systemd_acl_error('pstatus'); + my $s = $u->{'name'}; + + # Dependencies come from systemctl in the selected manager scope. + print_action_start(text('systemd_doingdeps', + mass_unit_label($u))); + my ($ok, $out) = $u->{'user_scope'} ? + dependencies_user_unit($u->{'user'}, $s) : + dependencies_unit($s); + print ui_tag('pre', html_escape($out)) if ($out); + print $text{'mass_failed'}, ui_p() if (!$ok && !$out); + } + mass_log('deps', \@units); + } + +# Get logs +if ($logs) { + # Show recent journal output for selected units. + foreach my $u (@units) { + systemd_can_logs(\%access, $u->{'user_scope'}, $u->{'user'}) || + systemd_acl_error('plogs'); + my $s = $u->{'name'}; + + # Logs are read through journalctl for both system and user units. + print_action_start(text('systemd_doinglogs', + mass_unit_label($u))); + my ($ok, $out) = $u->{'user_scope'} ? + logs_user_unit($u->{'user'}, $s) : + logs_unit($s); + print ui_tag('pre', html_escape($out)) if ($out); + print $text{'mass_failed'}, ui_p() if (!$ok && !$out); + } + mass_log('logs', \@units); + } + +# Stop or restart before any later enable/start work. +if ($stop || $restart) { + # Webmin itself cannot be stopped here, but it can be restarted specially. + $SIG{'TERM'} = 'ignore'; # Restarting webmin may kill this script + foreach my $u (@units) { + systemd_can_runtime(\%access, $stop ? 'stop' : 'restart', + $u->{'user_scope'}, $u->{'user'}) || + systemd_acl_error($stop ? 'pstop' : 'prestart'); + my $s = $u->{'name'}; + my ($ok, $out); + my $skipped = 0; + my $is_webmin = !$u->{'user_scope'} && $s eq 'webmin.service'; + + # Stop and restart are mutually exclusive submit actions. + if ($stop) { + print_action_start(text('mass_ustopping', + mass_unit_label($u))); + if (!$is_webmin) { + ($ok, $out) = $u->{'user_scope'} ? + stop_user_unit($u->{'user'}, $s) : + stop_unit($s); + } + } + elsif ($restart) { + print_action_start(text('mass_urestarting', + mass_unit_label($u))); + if (!unit_restartable($s)) { + ($ok, $out) = (1, $text{'mass_enorestart'}); + $skipped = 1; + } + elsif (!$is_webmin) { + ($ok, $out) = $u->{'user_scope'} ? + restart_user_unit($u->{'user'}, $s) : + restart_unit($s); + } + else { + restart_miniserv(); + } + } + + # Keep command output under the final per-unit result. + if ($is_webmin) { + print_action_result(1, text('mass_enoallow', $s), 1) + if ($stop); + print_action_result(1, undef, 0) + if ($restart); + } + else { + print_action_result($ok, $out, $skipped); + } + } + mass_log($stop ? 'massstop' : 'massrestart', \@units); + } + +# Enable or disable +if ($enable || $disable) { + # Enable or disable startup for each selected unit. + foreach my $u (@units) { + systemd_can_boot(\%access, $u->{'user_scope'}, $u->{'user'}) || + systemd_acl_error('pboot'); + my $b = $u->{'name'}; + my ($ok, $out) = (1, undef); + + # User units use systemctl --user; system units use the system manager. + if ($enable) { + print_action_start(text('mass_uenable', + mass_unit_label($u))); + if ($u->{'user_scope'}) { + ($ok, $out) = + enable_user_unit($u->{'user'}, $b); + } + else { + ($ok, $out) = enable_unit($b); + } + } + else { + print_action_start(text('mass_udisable', + mass_unit_label($u))); + if ($u->{'user_scope'}) { + ($ok, $out) = + disable_user_unit($u->{'user'}, $b); + } + else { + ($ok, $out) = disable_unit($b); + } + } + + # Keep command output under the final per-unit result. + print_action_result($ok, $out, startup_change_skipped($out)); + + } + mass_log($enable ? 'massenable' : 'massdisable', \@units); + } + +# Mask or unmask +if ($mask || $unmask) { + # Masking prevents activation; unmasking restores normal start behavior. + foreach my $u (@units) { + systemd_can_mask(\%access, $u->{'user_scope'}, $u->{'user'}) || + systemd_acl_error('pmask'); + my $b = $u->{'name'}; + my ($ok, $out); + + # User units use systemctl --user; system units use the system manager. + if ($mask) { + print_action_start(text('mass_umasking', + mass_unit_label($u))); + ($ok, $out) = $u->{'user_scope'} ? + mask_user_unit($u->{'user'}, $b) : + mask_unit($b); + } + else { + print_action_start(text('mass_uunmasking', + mass_unit_label($u))); + ($ok, $out) = $u->{'user_scope'} ? + unmask_user_unit($u->{'user'}, $b) : + unmask_unit($b); + } + + # Keep command output under the final per-unit result. + print_action_result($ok, $out, 0); + } + mass_log($mask ? 'massmask' : 'massunmask', \@units); + } + +# Delete user units +if ($delete) { + # Bulk delete is intentionally limited to user units. System unit + # deletion stays on the per-unit edit page where the risk is clearer. + foreach my $u (@units) { + $u->{'user_scope'} || error($text{'mass_edelete_user'}); + systemd_can_delete(\%access, 1, $u->{'user'}) || + systemd_acl_error('pdelete_user'); + my $s = $u->{'name'}; + print_action_start(text('mass_udeleting', + mass_unit_label($u))); + disable_user_unit($u->{'user'}, $s); + stop_user_unit($u->{'user'}, $s); + my ($ok, $out) = delete_user_unit($u->{'user'}, $s); + print_action_result($ok, $out, 0); + } + mass_log('massdelete', \@units); + } + +# Try to start at last +if ($start) { + # Start last, so "enable and start" first creates the wanted symlink. + foreach my $u (@units) { + systemd_can_runtime(\%access, 'start', + $u->{'user_scope'}, $u->{'user'}) || + systemd_acl_error('pstart'); + my $s = $u->{'name'}; + my ($ok, $out); + + # Each selected unit is started independently and reported inline. + print_action_start(text('mass_ustarting', + mass_unit_label($u))); + my $skipped = 0; + if (!unit_startable($s)) { + ($ok, $out) = (1, $text{'mass_enostart'}); + $skipped = 1; + } + else { + ($ok, $out) = $u->{'user_scope'} ? + start_user_unit($u->{'user'}, $s) : + start_unit($s); + } + print_action_result($ok, $out, $skipped); + } + mass_log('massstart', \@units); + } + +# Return to the unit page when it should still exist; otherwise return to its +# tab. Transient units can disappear after stop/restart actions. +if ($in{'return'} && !$in{'returnindex'}) { + my $dropin = $in{'returndropin'} ? "&dropin=1" : ""; + my $dropfile = $dropin && $in{'returndropfile'} ? + "&dropfile=".urlize(clean_unit_value($in{'returndropfile'})) : + ""; + my $return = $user_scope ? + "edit_unit.cgi?scope=user&unituser=".urlize($unituser). + "&name=".urlize($in{'return'}).$dropin.$dropfile : + "edit_unit.cgi?name=".urlize($in{'return'}).$dropin.$dropfile; + ui_print_footer($return, + $text{'systemd_return'}); + } +else { + my $u = $units[0]; + my $return = index_url($u->{'name'}, $u->{'user_scope'}, + $user_scope ? $unituser : undef); + ui_print_footer($return, $text{'index_return'}); + } + +# print_action_start(message) +# Prints the first progress line for a unit action. +sub print_action_start +{ +my ($msg) = @_; +if ($printed_action_result) { + print ui_tag('div', '', { 'class' => 'systemd-action-break', + 'style' => 'height: 1em;' }), "\n"; + $printed_action_result = 0; + } +print ui_tag('span', $msg, { 'data-first-print' => undef }); +print ui_br(), "\n"; +return; +} + +# print_action_result(ok, output, skipped, html) +# Prints the final result line with command output folded underneath it. +sub print_action_result +{ +my ($ok, $out, $skipped, $html) = @_; +my $status = $skipped ? $text{'mass_skipped'} : + $ok ? $text{'mass_ok'} : $text{'mass_failed'}; +my $title = ui_tag('span', html_escape($status), + { 'data-second-print' => undef }); +if (!defined($out) || $out eq "") { + print $title, "\n"; + $printed_action_result = 1; + return; + } + +# Keep successful output quiet, but open failures for immediate diagnosis. +my $content = $out; +$content = $html ? $content : + ui_tag('pre', html_escape($content), + { 'style' => 'margin-left: 10px;' }); +print ui_details({ + 'html' => 1, + 'title' => $title, + 'content' => $content, + 'class' => 'inline inlined', + }, !$ok && !$skipped); +print "\n"; +$printed_action_result = 1; +return; +} + +# mass_units(selected, user-scope, users-scope, user) +# Converts selected checkbox values into action records with optional owners. +sub mass_units +{ +my ($selected, $user_scope, $users_scope, $unituser) = @_; +my @rv; + +# The user-units tab packs owner and unit name into one checkbox value. +if ($users_scope) { + foreach my $raw (@$selected) { + my ($encuser, $encname) = split(/\t/, $raw, 2); + defined($encuser) && defined($encname) || + error($text{'systemd_euser'}); + my $user = clean_unit_value(un_urlize($encuser)); + my $name = un_urlize($encname); + get_user_details($user) || + error($text{'systemd_euser'}); + valid_unit_name($name) || + error($text{'systemd_ename'}); + push(@rv, { 'name' => $name, + 'user' => $user, + 'user_scope' => 1 }); + } + } +else { + # System-unit rows and single-user edit actions submit plain unit names. + foreach my $name (@$selected) { + valid_unit_name($name) || + error($text{'systemd_ename'}); + push(@rv, { 'name' => $name, + 'user' => $unituser, + 'user_scope' => $user_scope }); + } + } +return @rv; +} + +# mass_unit_label(unit) +# Returns escaped HTML for a unit name, including owner for user units. +sub mass_unit_label +{ +my ($unit) = @_; +my $name = ui_tag('tt', html_escape($unit->{'name'})); +return $name if (!$unit->{'user_scope'}); +return text('systemd_unit_for_user', $name, + ui_tag('tt', html_escape($unit->{'user'}))); +} + +# mass_log(action, units) +# Logs mixed system and user unit actions under the correct log type. +sub mass_log +{ +my ($action, $units) = @_; +my @system; +my %users; + +# Keep system and user actions separate so the log parser gets owner context. +foreach my $u (@$units) { + if ($u->{'user_scope'}) { + push(@{$users{$u->{'user'}}}, $u->{'name'}); + } + else { + push(@system, $u->{'name'}); + } + } + +# Group user-unit records by owner to avoid one log line per unit. +webmin_log($action, 'systemd', join(" ", @system)) if (@system); +foreach my $user (sort keys %users) { + webmin_log($action, 'systemd-user', join(" ", @{$users{$user}}), + { 'user' => $user }); + } +} diff --git a/systemd/module.info b/systemd/module.info new file mode 100644 index 000000000..731558d70 --- /dev/null +++ b/systemd/module.info @@ -0,0 +1,7 @@ +name=Systemd +category=system +os_support=*-linux +desc=Systemd Services and Units +longdesc=Manage systemd system and user units, including services, timers, sockets, paths and targets. +readonly=1 +syslog=1 diff --git a/systemd/restart.cgi b/systemd/restart.cgi new file mode 100755 index 000000000..34023fcd8 --- /dev/null +++ b/systemd/restart.cgi @@ -0,0 +1,31 @@ +#!/usr/local/bin/perl +# Reload the system systemd manager after unit-file changes. + +use strict; +use warnings; + +require './systemd-lib.pl'; ## no critic + +our (%access, %text); + +ReadParse(); +error_setup($text{'reload_err'}); +systemd_can_reload(\%access) || systemd_acl_error('preload'); + +ui_print_unbuffered_header(undef, $text{'reload_title'}, ""); + +# Run daemon-reload directly so command output can be shown to the admin. +my $systemctl = has_command("systemctl"); +$systemctl || error($text{'systemd_esystemctl'}); +print $text{'reload_doing'}, ui_br(), "\n"; +my $out = backquote_logged( + quotemeta($systemctl)." daemon-reload 2>&1 {'user'}) || + systemd_acl_error('pmanual_user'); + +ui_print_unbuffered_header(undef, $text{'reload_user_title'}, ""); + +print text('reload_user_doing', + ui_tag('tt', html_escape($uinfo->{'user'}))), ui_br(), "\n"; +my ($ok, $out) = reload_user_manager($uinfo->{'user'}); +print ui_tag('pre', html_escape($out)) if ($out); +print($ok ? $text{'mass_ok'} : $text{'mass_failed'}, ui_p()); +if ($ok) { + mark_user_daemon_reloaded($uinfo->{'user'}); + webmin_log("reload", "systemd-user", $uinfo->{'user'}, + { 'user' => $uinfo->{'user'} }); + } + +ui_print_footer("index.cgi?scope=user&unituser=".urlize($uinfo->{'user'}), + $text{'index_return'}); diff --git a/systemd/safeacl b/systemd/safeacl new file mode 100644 index 000000000..d07ef302e --- /dev/null +++ b/systemd/safeacl @@ -0,0 +1,34 @@ +noconfig=1 +mode=3 +users= +uidmin= +uidmax= +view=0 +view_user=1 +status=0 +status_user=1 +logs=0 +logs_user=1 +start=0 +start_user=1 +stop=0 +stop_user=1 +restart=0 +restart_user=1 +boot=0 +boot_user=1 +mask=0 +mask_user=0 +create=0 +create_user=1 +edit=0 +edit_user=1 +delete=0 +delete_user=1 +dropin=0 +dropin_user=1 +manual=0 +manual_user=1 +reload=0 +linger=1 +backup=0 diff --git a/systemd/save_manual.cgi b/systemd/save_manual.cgi new file mode 100755 index 000000000..6dd3aa89c --- /dev/null +++ b/systemd/save_manual.cgi @@ -0,0 +1,34 @@ +#!/usr/local/bin/perl +# Save a raw systemd unit file selected by edit_manual.cgi. + +use strict; +use warnings; + +require './systemd-lib.pl'; ## no critic + +our (%access, %in, %text); + +ReadParseMime(); +error_setup($text{'manual_err'}); + +# The posted path must still be in the discovered allowlist at save time. +my $info = manual_unit_file($in{'file'}); +$info || error($text{'manual_efile'}); +systemd_can_manual(\%access, $info) || + systemd_acl_error($info->{'scope'} eq 'user' ? + 'pmanual_user' : 'pmanual'); +my ($ok, $err) = write_manual_unit_file($info, $in{'data'}); +$ok || error($err || $text{'manual_ewrite'}); + +# User-unit edits include the owner so the log parser can render context. +if ($info->{'scope'} eq 'user') { + mark_user_units_changed($info->{'user'}); + webmin_log("manual", "systemd-user", $info->{'file'}, + { 'user' => $info->{'user'} }); + redirect("index.cgi?scope=user&unituser=".urlize($info->{'user'})); + } +else { + mark_units_changed(); + webmin_log("manual", "systemd", $info->{'file'}); + redirect(""); + } diff --git a/systemd/save_unit.cgi b/systemd/save_unit.cgi new file mode 100755 index 000000000..8b1b945bf --- /dev/null +++ b/systemd/save_unit.cgi @@ -0,0 +1,854 @@ +#!/usr/local/bin/perl +# Create, update or delete a systemd unit + +use strict; +use warnings; +use Symbol qw(gensym); + +require './systemd-lib.pl'; ## no critic + +our (%access, %config, %in, %text); + +# All failures on this page should use systemd-specific wording. +error_setup($text{'systemd_err'}); +ReadParse(); + +# Select system or user scope before loading units. +my $user_scope = $in{'new'} ? ($in{'userservice'} ? 1 : 0) : + ($in{'scope'} eq 'user' ? 1 : 0); +my $unituser = clean_unit_value($in{'unituser'}); +my $edit_dropin = !$in{'new'} && $in{'dropin'} ? 1 : 0; +my $dropin_file = $edit_dropin ? clean_unit_value($in{'dropfile'}) : ""; +my $dropin_info; +my (@units, $u, $redirect); +if ($user_scope) { + # User units must always be tied to a real Unix account. + 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 units are managed through the system manager. + systemd_can_view_scope(\%access, 0) || systemd_acl_error('pview'); + @units = list_units(); + } + +# Load the existing unit for edits and destructive actions. +if (!$in{'new'}) { + valid_unit_name($in{'name'}) || + error($text{'systemd_ename'}); + + # The target unit must exist in the selected scope before it can be edited. + ($u) = grep { $_->{'name'} eq $in{'name'} } @units; + $u || error($text{'systemd_egone'}); + if ($edit_dropin && $dropin_file) { + $dropin_info = $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'}; + } + } + +if ($in{'stock_unit'}) { + # Leaving the override editor is navigation only; do not save form data. + redirect($user_scope ? + "edit_unit.cgi?scope=user&unituser=".urlize($unituser). + "&name=".urlize($in{'name'}) : + "edit_unit.cgi?name=".urlize($in{'name'})); + exit; + } + +# Runtime actions do not save the form; they stream through mass_units.cgi. +if (!$in{'new'} && + ($in{'start'} || $in{'stop'} || $in{'restart'} || $in{'status'} || + $in{'props'} || $in{'deps'} || $in{'logs'})) { + if ($in{'start'}) { + systemd_can_runtime(\%access, 'start', + $user_scope, $unituser) || + systemd_acl_error('pstart'); + } + elsif ($in{'stop'}) { + systemd_can_runtime(\%access, 'stop', + $user_scope, $unituser) || + systemd_acl_error('pstop'); + } + elsif ($in{'restart'}) { + systemd_can_runtime(\%access, 'restart', + $user_scope, $unituser) || + systemd_acl_error('prestart'); + } + elsif ($in{'logs'}) { + systemd_can_logs(\%access, $user_scope, $unituser) || + systemd_acl_error('plogs'); + } + else { + systemd_can_inspect(\%access, $user_scope, $unituser) || + systemd_acl_error('pstatus'); + } + # Stream runtime actions through mass_units.cgi. + my $scopeargs = $user_scope ? "&scope=user&unituser=". + urlize($unituser) : ""; + my $dropinargs = $edit_dropin ? "&returndropin=1" : ""; + $dropinargs .= "&returndropfile=".urlize($dropin_file) + if ($dropin_file); + my $returnindexargs = + (!$edit_dropin && !unit_file_editable($u) && + ($in{'stop'} || $in{'restart'})) ? "&returnindex=1" : ""; + redirect("mass_units.cgi?d=".urlize($in{'name'})."&". + ($in{'start'} ? "start=1" : + $in{'restart'} ? "restart=1" : + $in{'status'} ? "status=1" : + $in{'props'} ? "props=1" : + $in{'deps'} ? "deps=1" : + $in{'logs'} ? "logs=1" : "stop=1"). + "&return=".urlize($in{'name'}).$scopeargs.$dropinargs. + $returnindexargs); + exit; + } + +if ($in{'override'}) { + # Create the standard override file if needed, then open that drop-in. + systemd_can_dropin(\%access, $user_scope, $unituser) || + systemd_acl_error($user_scope ? 'pdropin_user' : 'pdropin'); + unit_file_editable($u) || error($text{'systemd_ereadonly'}); + my $base_data = $user_scope ? + read_user_unit_file($unituser, $u->{'file'}) : + read_file_contents($u->{'file'}); + defined($base_data) || + error($user_scope ? $text{'systemd_euserunitfile'} : + $text{'manual_eread'}); + my $dropfile = $user_scope ? + user_dropin_file($unituser, $in{'name'}) : + system_dropin_file($in{'name'}); + $dropfile || error($text{'systemd_edropinfile'}); + + # Existing override files are preserved; the button becomes an opener. + if ($user_scope) { + user_dropin_file_safe($unituser, $dropfile, 0) || + error($text{'systemd_edropinfile'}); + if (!-f $dropfile) { + my $template = + dropin_template($dropfile, $u->{'file'}, + $base_data); + my ($ok, $out) = write_user_dropin_file( + $unituser, $in{'name'}, $template); + $ok || error($out); + webmin_log("override", "systemd-user", $in{'name'}, + { 'user' => $unituser }); + } + $redirect = "edit_unit.cgi?scope=user&unituser=". + urlize($unituser)."&name=".urlize($in{'name'}). + "&dropin=1"; + } + else { + my $dir = $dropfile; + $dir =~ s{/[^/]+$}{}; + error($text{'systemd_edropinfile'}) + if (-l $dir || (-e $dir && !-d $dir) || + -l $dropfile || (-e $dropfile && !-f $dropfile)); + if (!-f $dropfile) { + my $template = + dropin_template($dropfile, $u->{'file'}, + $base_data); + my ($ok, $out) = + write_system_dropin_file($in{'name'}, + $template); + $ok || error($out); + webmin_log("override", "systemd", $in{'name'}); + } + $redirect = "edit_unit.cgi?name=".urlize($in{'name'}). + "&dropin=1"; + } + } +elsif ($in{'delete_override'}) { + # Drop-in deletes are available only from the override editor. + systemd_can_dropin(\%access, $user_scope, $unituser) || + systemd_acl_error($user_scope ? 'pdropin_user' : 'pdropin'); + $edit_dropin || error($text{'systemd_edropinfile'}); + $dropin_file && error($text{'systemd_edropinfile'}); + unit_file_editable($u) || error($text{'systemd_ereadonly'}); + if ($user_scope) { + my ($ok, $out) = + delete_user_dropin_file($unituser, $in{'name'}); + $ok || error($out); + ($ok, $out) = reload_user_manager($unituser); + $ok || error_user_command($unituser, $out); + webmin_log("deleteoverride", "systemd-user", $in{'name'}, + { 'user' => $unituser }); + $redirect = "edit_unit.cgi?scope=user&unituser=". + urlize($unituser)."&name=".urlize($in{'name'}); + } + else { + my ($ok, $out) = delete_system_dropin_file($in{'name'}); + $ok || error($out); + reload_manager(); + webmin_log("deleteoverride", "systemd", $in{'name'}); + $redirect = "edit_unit.cgi?name=".urlize($in{'name'}); + } + } +elsif ($in{'delete'}) { + # Delete the unit after trying to stop it and remove it from startup. + systemd_can_delete(\%access, $user_scope, $unituser) || + systemd_acl_error($user_scope ? 'pdelete_user' : 'pdelete'); + if ($user_scope) { + # User-unit deletion goes through helpers that drop to the owner. + disable_user_unit($unituser, $in{'name'}); + stop_user_unit($unituser, $in{'name'}); + my ($ok, $out) = + delete_user_unit($unituser, $in{'name'}); + $ok || error_user_command($unituser, $out); + webmin_log("delete", "systemd-user", $in{'name'}, + { 'user' => $unituser }); + } + else { + # Stop and disable are best-effort, but deletion must be reported. + disable_unit($in{'name'}); + stop_unit($in{'name'}); + my ($ok, $out) = delete_system_unit($in{'name'}); + $ok || error($out); + webmin_log("delete", "systemd", $in{'name'}); + } + $redirect = index_url($in{'name'}, $user_scope, $unituser); + } +elsif ($in{'new'}) { + systemd_can_create(\%access, $user_scope, $user_scope ? $unituser : undef) || + systemd_acl_error($user_scope ? 'pcreate_user' : 'pcreate'); + # Normalize the unit name and suffix before checking for clashes. + my @creatable_unit_types = get_creatable_unit_types($user_scope); + my %creatable_types = map { $_, 1 } @creatable_unit_types; + my $unittype = $in{'unittype'} || 'service'; + $creatable_types{$unittype} || error($text{'systemd_eunittype'}); + $in{'name'} = clean_unit_value($in{'name'}); + $in{'name'} = "" if (!defined($in{'name'})); + + # Guided fields are rendered into the correct type-specific section. + my ($derived_name, $structured_body); + foreach my $o ('timer_oncalendar', 'timer_onbootsec', + 'timer_onunitactivesec', 'timer_randomizeddelaysec', + 'timer_accuracysec', 'timer_unit', 'timer_persistent', + 'socket_listenstream', 'socket_listendatagram', + 'socket_listenfifo', 'socket_user', 'socket_group', + 'socket_mode', 'socket_service', 'socket_accept', + 'path_exists', 'path_existsglob', 'path_changed', + 'path_modified', 'path_directorynotempty', + 'path_makedirectory', 'path_unit', + 'mount_what', 'mount_where', 'mount_type', + 'mount_options', 'automount_mount', 'automount_where', + 'automount_idle', 'automount_mode', + 'swap_what', 'swap_priority', 'swap_options', + 'swap_timeoutsec', 'slice_cpuweight', + 'slice_memorymax', 'slice_tasksmax', 'slice_ioweight') { + $in{$o} = clean_unit_value($in{$o}); + $in{$o} = "" if (!defined($in{$o})); + } + my $raw_unitconf = clean_unit_body($in{'unitconf'}); + $raw_unitconf = "" if (!defined($raw_unitconf)); + $raw_unitconf =~ /^\s*\[/m && + error($text{'systemd_eunitconfsection'}); + + if ($unittype eq 'timer') { + my %timer_labels = ( + 'timer_onbootsec' => $text{'systemd_timeronbootsec'}, + 'timer_onunitactivesec' => + $text{'systemd_timeronunitactivesec'}, + 'timer_randomizeddelaysec' => + $text{'systemd_timerrandomizeddelaysec'}, + 'timer_accuracysec' => $text{'systemd_timeraccuracysec'}, + ); + foreach my $o ('timer_onbootsec', 'timer_onunitactivesec', + 'timer_randomizeddelaysec', + 'timer_accuracysec') { + !$in{$o} || valid_duration($in{$o}) || + error(text('systemd_eduration', $timer_labels{$o})); + } + !$in{'timer_unit'} || valid_unit_name($in{'timer_unit'}) || + error($text{'systemd_etimerunit'}); + my $has_timer = $in{'timer_oncalendar'} || + $in{'timer_onbootsec'} || + $in{'timer_onunitactivesec'} || + $in{'timer_persistent'} || + $in{'timer_randomizeddelaysec'} || + $in{'timer_accuracysec'} || + $in{'timer_unit'}; + my $has_trigger = $in{'timer_oncalendar'} || + $in{'timer_onbootsec'} || + $in{'timer_onunitactivesec'}; + $has_trigger || $raw_unitconf =~ /\S/ || + error($text{'systemd_etimertrigger'}); + $structured_body = render_timer_body({ + 'oncalendar' => $in{'timer_oncalendar'}, + 'onbootsec' => $in{'timer_onbootsec'}, + 'onunitactivesec' => $in{'timer_onunitactivesec'}, + 'persistent' => $in{'timer_persistent'}, + 'randomizeddelaysec' => + $in{'timer_randomizeddelaysec'}, + 'accuracysec' => $in{'timer_accuracysec'}, + 'unit' => $in{'timer_unit'}, + }) if ($has_timer); + } + elsif ($unittype eq 'socket') { + # User managers create filesystem sockets as the owning user. + if ($user_scope) { + $in{'socket_user'} = ""; + $in{'socket_group'} = ""; + } + foreach my $o ('socket_listenstream', + 'socket_listendatagram') { + !$in{$o} || $in{$o} =~ /^\S+$/ || + error($text{'systemd_esocketlisten'}); + } + !$in{'socket_listenfifo'} || + valid_path($in{'socket_listenfifo'}, 0, 0, 0) || + error(text('systemd_epath', + $text{'systemd_socketlistenfifo'})); + !$in{'socket_mode'} || $in{'socket_mode'} =~ /^[0-7]{3,4}$/ || + error($text{'systemd_esocketmode'}); + !$in{'socket_service'} || + (valid_unit_name($in{'socket_service'}) && + $in{'socket_service'} =~ /\.service$/) || + error($text{'systemd_esocketservice'}); + my $has_listener = $in{'socket_listenstream'} || + $in{'socket_listendatagram'} || + $in{'socket_listenfifo'}; + $has_listener || $raw_unitconf =~ /\S/ || + error($text{'systemd_esocketlisten'}); + my $has_socket = $has_listener || $in{'socket_accept'} || + $in{'socket_user'} || $in{'socket_group'} || + $in{'socket_mode'} || $in{'socket_service'}; + $structured_body = render_socket_body({ + 'listenstream' => $in{'socket_listenstream'}, + 'listendatagram' => $in{'socket_listendatagram'}, + 'listenfifo' => $in{'socket_listenfifo'}, + 'accept' => $in{'socket_accept'}, + 'user' => $in{'socket_user'}, + 'group' => $in{'socket_group'}, + 'mode' => $in{'socket_mode'}, + 'service' => $in{'socket_service'}, + }) if ($has_socket); + } + elsif ($unittype eq 'path') { + my %path_labels = ( + 'path_exists' => $text{'systemd_pathexists'}, + 'path_existsglob' => $text{'systemd_pathexistsglob'}, + 'path_changed' => $text{'systemd_pathchanged'}, + 'path_modified' => $text{'systemd_pathmodified'}, + 'path_directorynotempty' => + $text{'systemd_pathdirectorynotempty'}, + ); + foreach my $o ('path_exists', 'path_existsglob', + 'path_changed', 'path_modified', + 'path_directorynotempty') { + !$in{$o} || valid_path($in{$o}, 0, 0, 0) || + error(text('systemd_epath', $path_labels{$o})); + } + !$in{'path_unit'} || valid_unit_name($in{'path_unit'}) || + error($text{'systemd_epathunit'}); + my $has_path = $in{'path_exists'} || $in{'path_existsglob'} || + $in{'path_changed'} || + $in{'path_modified'} || + $in{'path_directorynotempty'}; + $has_path || $raw_unitconf =~ /\S/ || + error($text{'systemd_epathtrigger'}); + $structured_body = render_path_body({ + 'exists' => $in{'path_exists'}, + 'existsglob' => $in{'path_existsglob'}, + 'changed' => $in{'path_changed'}, + 'modified' => $in{'path_modified'}, + 'directorynotempty' => $in{'path_directorynotempty'}, + 'makedirectory' => $in{'path_makedirectory'}, + 'unit' => $in{'path_unit'}, + }) if ($has_path || $in{'path_makedirectory'} || + $in{'path_unit'}); + } + elsif ($unittype eq 'mount' && + ($in{'mount_what'} || $in{'mount_where'} || + $in{'mount_type'} || $in{'mount_options'})) { + $in{'mount_what'} =~ /\S/ || + error($text{'systemd_emountwhat'}); + valid_path($in{'mount_where'}, 0, 0, 0) || + error(text('systemd_epath', + $text{'systemd_mountwhere'})); + $derived_name = path_unit_name($in{'mount_where'}, 'mount') || + error(text('systemd_epath', + $text{'systemd_mountwhere'})); + $structured_body = render_mount_body( + $in{'mount_what'}, $in{'mount_where'}, + $in{'mount_type'}, $in{'mount_options'}); + } + elsif ($unittype eq 'automount' && + ($in{'automount_mount'} || $in{'automount_where'} || + $in{'automount_idle'} || $in{'automount_mode'})) { + my $selected = $in{'automount_mount'}; + if ($selected) { + valid_creatable_unit_name($selected, $user_scope) && + $selected =~ /\.mount$/ || + error($text{'systemd_eautomountmount'}); + my ($mount) = grep { $_->{'name'} eq $selected } @units; + $mount || error($text{'systemd_eautomountmount'}); + $in{'automount_where'} = mount_unit_where( + $mount, $user_scope ? $unituser : undef); + } + valid_path($in{'automount_where'}, 0, 0, 0) || + error(text('systemd_epath', + $text{'systemd_automountwhere'})); + !$in{'automount_idle'} || valid_duration($in{'automount_idle'}) || + error(text('systemd_eduration', + $text{'systemd_automountidle'})); + !$in{'automount_mode'} || + $in{'automount_mode'} =~ /^[0-7]{3,4}$/ || + error($text{'systemd_eautomountmode'}); + my $mount_name = + path_unit_name($in{'automount_where'}, 'mount') || + error(text('systemd_epath', + $text{'systemd_automountwhere'})); + my ($mount) = grep { $_->{'name'} eq $mount_name } @units; + $mount || error($text{'systemd_eautomountmount'}); + $derived_name = + path_unit_name($in{'automount_where'}, 'automount') || + error(text('systemd_epath', + $text{'systemd_automountwhere'})); + $structured_body = render_automount_body( + $in{'automount_where'}, $in{'automount_idle'}, + $in{'automount_mode'}); + } + elsif ($unittype eq 'swap') { + !$in{'swap_what'} || valid_path($in{'swap_what'}, 0, 0, 0) || + error(text('systemd_epath', $text{'systemd_swapwhat'})); + !$in{'swap_priority'} || $in{'swap_priority'} =~ /^-?\d+$/ || + error($text{'systemd_eswappriority'}); + !$in{'swap_timeoutsec'} || valid_duration($in{'swap_timeoutsec'}) || + error(text('systemd_eduration', + $text{'systemd_swaptimeoutsec'})); + $in{'swap_what'} || $raw_unitconf =~ /\S/ || + error($text{'systemd_eswapwhat'}); + $structured_body = render_swap_body({ + 'what' => $in{'swap_what'}, + 'priority' => $in{'swap_priority'}, + 'options' => $in{'swap_options'}, + 'timeoutsec' => $in{'swap_timeoutsec'}, + }) if ($in{'swap_what'} || $in{'swap_priority'} || + $in{'swap_options'} || $in{'swap_timeoutsec'}); + } + elsif ($unittype eq 'slice') { + foreach my $o ('slice_cpuweight', 'slice_ioweight') { + !$in{$o} || ($in{$o} =~ /^\d+$/ && + $in{$o} >= 1 && $in{$o} <= 10000) || + error($text{'systemd_esliceweight'}); + } + foreach my $o ('slice_memorymax', 'slice_tasksmax') { + !$in{$o} || $in{$o} =~ /^(infinity|\S+)$/ || + error($text{'systemd_eslicelimit'}); + } + $structured_body = render_slice_body({ + 'cpuweight' => $in{'slice_cpuweight'}, + 'memorymax' => $in{'slice_memorymax'}, + 'tasksmax' => $in{'slice_tasksmax'}, + 'ioweight' => $in{'slice_ioweight'}, + }) if ($in{'slice_cpuweight'} || + $in{'slice_memorymax'} || + $in{'slice_tasksmax'} || + $in{'slice_ioweight'}); + } + $in{'name'} ||= $derived_name if ($derived_name); + + # Users may type the suffix or choose it from the dropdown; keep them equal. + my $creatable_piped = join('|', map { quotemeta($_) } + @creatable_unit_types); + if ($in{'name'} =~ /\.($creatable_piped)$/i) { + lc($1) eq $unittype || error($text{'systemd_eunittype'}); + $in{'name'} =~ s/\.($creatable_piped)$/\.$unittype/i; + } + else { + $in{'name'} .= ".".$unittype; + } + valid_creatable_unit_name($in{'name'}, $user_scope) || + error($text{'systemd_ename'}); + if ($derived_name && $in{'name'} ne $derived_name) { + error($unittype eq 'mount' ? + $text{'systemd_emountname'} : + $text{'systemd_eautomountname'}); + } + + # Refuse to overwrite an existing unit in the selected manager. + my ($clash) = grep { $_->{'name'} eq $in{'name'} } @units; + $clash && error($text{'systemd_eclash'}); + $in{'desc'} || error($text{'systemd_edesc'}); + + # Services use explicit command fields; other unit types accept only the + # body of their type-specific section, which is wrapped server-side. + if ($unittype eq 'service') { + $in{'atstart'} =~ /\S/ || error($text{'systemd_estart'}); + } + else { + $in{'unitconf'} = ""; + if (defined($structured_body) && $structured_body =~ /\S/) { + $in{'unitconf'} = $structured_body; + $in{'unitconf'} .= "\n" if ($raw_unitconf =~ /\S/); + } + $in{'unitconf'} .= $raw_unitconf; + my %empty_ok = ( 'target' => 1, 'slice' => 1 ); + $empty_ok{$unittype} || $in{'unitconf'} =~ /\S/ || + error($text{'systemd_eunitconf'}); + } + + # Parse optional scalar settings into %opts. + my %opts; + $in{'restart'} = $in{'restart_policy'} + if ($in{'new'} && defined($in{'restart_policy'})); + + # These options map to single-line unit directives, so line breaks collapse. + foreach my $o ('before', 'after', 'wants', 'requires', 'conflicts', + 'onfailure', 'onsuccess', 'type', 'env', 'envfile', + 'user', 'group', 'killmode', 'workdir', 'restart', + 'restartsec', 'watchdogsec', 'timeout', + 'timeoutstartsec', + 'timeoutstopsec', 'limitnofile', 'logstd', 'logerr', + 'syslogid', 'protectsystem', 'readwritepaths', + 'wantedby') { + if (defined($in{$o})) { + $in{$o} =~ s/\r|\n/ /g; + $in{$o} =~ s/^\s+//; + $in{$o} =~ s/\s+$//; + $opts{$o} = $in{$o} if ($in{$o} =~ /\S/); + } + } + + # Keep one command hook per input line. + foreach my $o ('startpre', 'startpost', 'stoppost') { + if (defined($in{$o})) { + $in{$o} =~ s/\r//g; + $in{$o} =~ s/^\s+//; + $in{$o} =~ s/\s+$//; + $opts{$o} = $in{$o} if ($in{$o} =~ /\S/); + } + } + + # Reload and PID file use dedicated service fields rather than %opts. + foreach my $o ('reload', 'pidfile') { + $in{$o} = "" if (!defined($in{$o})); + $in{$o} =~ s/\r//g; + $in{$o} =~ s/\n/ /g if ($o eq 'pidfile'); + $in{$o} =~ s/^\s+//; + $in{$o} =~ s/\s+$//; + } + + # Boolean options are emitted only when enabled. + foreach my $o ('nonewprivs', 'privatetmp') { + $opts{$o} = 1 if ($in{$o}); + } + my %duration_text = ( + 'restartsec' => $text{'systemd_restartsec'}, + 'watchdogsec' => $text{'systemd_watchdogsec'}, + 'timeout' => $text{'systemd_timeout'}, + 'timeoutstartsec' => $text{'systemd_timeout'}, + 'timeoutstopsec' => $text{'systemd_timeoutstop'}, + ); + + # Service-only options are validated against systemd's expected value + # shapes, so invalid units fail on save instead of on daemon-reload. + if ($unittype eq 'service') { + # Validate duration-like values before writing them to the unit file. + foreach my $o ('restartsec', 'watchdogsec', 'timeout', + 'timeoutstartsec', 'timeoutstopsec') { + !$opts{$o} || valid_duration($opts{$o}) || + error(text('systemd_eduration', $duration_text{$o})); + } + !$in{'pidfile'} || valid_path($in{'pidfile'}, 0, 0) || + error(text('systemd_epath', $text{'systemd_pidfile'})); + !$opts{'workdir'} || valid_path($opts{'workdir'}, 1, 1) || + error(text('systemd_epath', $text{'systemd_workdir'})); + !$opts{'envfile'} || valid_path($opts{'envfile'}, 1, 0) || + error(text('systemd_epath', $text{'systemd_envfile'})); + !$opts{'limitnofile'} || + $opts{'limitnofile'} =~ /^(infinity|\d+)(:(infinity|\d+))?$/i || + error($text{'systemd_elimitnofile'}); + foreach my $o ('logstd', 'logerr') { + !$opts{$o} || valid_output($opts{$o}) || + error(text('systemd_eoutput', $text{'systemd_'.$o})); + } + !$opts{'protectsystem'} || + $opts{'protectsystem'} =~ /^(true|full|strict)$/ || + error($text{'systemd_eprotectsystem'}); + + # ReadWritePaths can contain several shell-style path words. + if ($opts{'readwritepaths'}) { + foreach my $p (split_quoted_string($opts{'readwritepaths'})) { + valid_path($p, 1, 0, 1) || + error(text('systemd_ereadwritepath', $p)); + } + } + } + + # User units already run as the owning user, so User=/Group= must not be + # written into the unit file. + if ($user_scope) { + delete($opts{'user'}); + delete($opts{'group'}); + } + $opts{'wantedby'} ||= get_default_install_target( + $unittype, $user_scope); + + # Render the unit once, then write the same bytes to the selected scope. + my %unit = ( 'type' => $unittype, + 'description' => $in{'desc'}, + 'options' => \%opts ); + if ($unittype eq 'service') { + $unit{'service'} = { 'start' => $in{'atstart'}, + 'stop' => $in{'atstop'}, + 'reload' => $in{'reload'}, + 'pidfile' => $in{'pidfile'}, + 'remain' => $in{'remain'} }; + } + else { + $unit{'body'} = $in{'unitconf'}; + } + my $unit_data = render_unit(\%unit); + + # Create the unit file in the selected scope. When requested, linger is + # enabled first so daemon-reload has a user manager to talk to. + if ($user_scope) { + # Linger is optional on create, but enabling it also starts the manager. + if ($in{'linger'}) { + systemd_can_linger(\%access, $unituser) || + systemd_acl_error('plinger'); + my ($lok, $lout) = set_user_linger($unituser, 1); + $lok || error_user_command($unituser, $lout); + my ($mok, $mout) = start_user_manager($unituser); + $mok || error_user_command($unituser, $mout); + } + my ($ok, $out, $kind) = create_user_unit( + $unituser, $in{'name'}, $unit_data); + if (!$ok) { + $kind && $kind ne 'command' ? + error($out) : error_user_command($unituser, $out); + } + } + else { + # System-scope units are written under the local systemd unit root. + my ($ok, $out) = create_system_unit($in{'name'}, $unit_data); + $ok || error($out); + } + + # Enable or disable startup after the unit has been written and reloaded. + if (defined($in{'boot'}) && + systemd_can_boot(\%access, $user_scope, $unituser)) { + if ($user_scope) { + my ($ok, $out); + + # User enable/disable failures include the systemctl output. + if ($in{'boot'} == 0) { + ($ok, $out) = + disable_user_unit($unituser, + $in{'name'}); + } + else { + ($ok, $out) = + enable_user_unit($unituser, + $in{'name'}); + } + $ok || error_user_command($unituser, $out); + } + else { + # System enable/disable uses the existing Webmin error path. + if ($in{'boot'} == 0) { + my ($ok, $out) = disable_unit($in{'name'}); + $ok || error($out); + } + else { + my ($ok, $out) = enable_unit($in{'name'}); + $ok || error($out); + } + } + } + + # Log the create event, then return to the configured destination. + if ($user_scope) { + webmin_log("create", "systemd-user", $in{'name'}, + { 'user' => $unituser }); + } + else { + webmin_log("create", "systemd", $in{'name'}); + } + + if ($config{'create_return_index'} eq '1') { + $redirect = index_url($in{'name'}, $user_scope, $unituser); + } + elsif ($user_scope) { + $redirect = "edit_unit.cgi?scope=user&unituser=". + urlize($unituser)."&name=".urlize($in{'name'}); + } + else { + $redirect = "edit_unit.cgi?name=".urlize($in{'name'}); + } + } +else { + # Save the raw unit file contents from the edit form. + my $can_save_unit = $edit_dropin ? + systemd_can_dropin(\%access, $user_scope, $unituser) : + systemd_can_edit(\%access, $user_scope, $unituser); + $can_save_unit || + systemd_acl_error($edit_dropin ? + ($user_scope ? 'pdropin_user' : 'pdropin') : + ($user_scope ? 'pedit_user' : 'pedit')); + if (!unit_file_editable($u)) { + error($text{'systemd_ereadonly'}); + } + $in{'data'} =~ /\S/ || error($text{'systemd_econf'}); + $in{'data'} =~ s/\r//g; + my $save_data = $edit_dropin ? + dropin_effective_data($in{'data'}) : $in{'data'}; + my $base_data; + my ($vok, $vout); + if ($edit_dropin) { + # Drop-ins are verified together with the base unit they override. + $base_data = $user_scope ? + read_user_unit_file($unituser, $u->{'file'}) : + read_file_contents($u->{'file'}); + defined($base_data) || + error($user_scope ? $text{'systemd_euserunitfile'} : + $text{'manual_eread'}); + ($vok, $vout) = + verify_dropin_data($u->{'file'}, $base_data, + $save_data, $user_scope, + $u->{'unitstate'}, $unituser); + } + else { + # Full unit edits are verified directly under their unit basename. + ($vok, $vout) = + verify_unit_data($u->{'file'}, $save_data, + $user_scope, $unituser); + } + $vok || error($vout); + if ($user_scope) { + # User unit writes go through a privilege-dropped helper. Linger is + # disabled only after daemon-reload succeeds, so the reload is not cut + # off from the user manager. + my $disable_linger; + my ($wok, $wout) = $edit_dropin && $dropin_file ? + write_user_dropin_config_file($unituser, $dropin_file, + $save_data) : + $edit_dropin ? + write_user_dropin_file($unituser, $in{'name'}, + $save_data) : + write_user_unit_file($unituser, $u->{'file'}, + $save_data); + $wok || error($wout); + + # Enabling linger happens before reload; disabling waits until after. + if (defined($in{'linger'})) { + systemd_can_linger(\%access, $unituser) || + systemd_acl_error('plinger'); + if ($in{'linger'}) { + my ($lok, $lout) = + set_user_linger($unituser, 1); + $lok || error_user_command($unituser, $lout); + my ($mok, $mout) = + start_user_manager($unituser); + $mok || error_user_command($unituser, $mout); + } + else { + $disable_linger = 1; + } + } + my ($ok, $out) = reload_user_manager($unituser); + $ok || error_user_command($unituser, $out); + + # Disable linger only after the user manager has accepted daemon-reload. + if ($disable_linger) { + my ($lok, $lout) = set_user_linger($unituser, 0); + $lok || error_user_command($unituser, $lout); + } + } + else { + # System units are root-owned and can be updated directly. + if ($edit_dropin && $dropin_file) { + my ($wok, $wout) = + write_system_dropin_config_file($dropin_file, + $save_data); + $wok || error($wout); + } + elsif ($edit_dropin) { + my ($wok, $wout) = + write_system_dropin_file($in{'name'}, + $save_data); + $wok || error($wout); + } + else { + my $conf_fh = gensym(); + open_lock_tempfile($conf_fh, ">$u->{'file'}"); + print_tempfile($conf_fh, $save_data); + close_tempfile($conf_fh); + } + reload_manager(); + } + + # Apply startup state changes after saving the config. + if (defined($in{'boot'}) && + boot_state_changeable($u->{'unitstate'}, $u->{'name'})) { + systemd_can_boot(\%access, $user_scope, $unituser) || + systemd_acl_error('pboot'); + if ($user_scope) { + my ($ok, $out); + + # Startup state is managed through the same scoped manager as edit. + if ($in{'boot'} == 0) { + ($ok, $out) = + disable_user_unit( + $unituser, $in{'name'}); + } + else { + ($ok, $out) = + enable_user_unit( + $unituser, $in{'name'}); + } + $ok || error_user_command($unituser, $out); + } + else { + # System-unit startup state is independent of the raw file write. + if ($in{'boot'} == 0) { + my ($ok, $out) = + disable_unit($in{'name'}); + $ok || error($out); + } + else { + my ($ok, $out) = + enable_unit($in{'name'}); + $ok || error($out); + } + } + } + + # Log the edit and return to the same scoped edit page. + if ($user_scope) { + webmin_log("modify", "systemd-user", $in{'name'}, + { 'user' => $unituser }); + $redirect = "edit_unit.cgi?scope=user&unituser=". + urlize($unituser)."&name=".urlize($in{'name'}). + ($edit_dropin ? "&dropin=1" : ""). + ($dropin_file ? "&dropfile=".urlize($dropin_file) : ""); + } + else { + webmin_log("modify", "systemd", $in{'name'}); + $redirect = "edit_unit.cgi?name=".urlize($in{'name'}). + ($edit_dropin ? "&dropin=1" : ""). + ($dropin_file ? "&dropfile=".urlize($dropin_file) : ""); + } + } +redirect($redirect || ""); + +# error_user_command(user, output) +# Shows a systemctl --user or loginctl failure with escaped command output. +sub error_user_command +{ +my ($user, $out) = @_; +$out ||= $text{'systemd_euser'}; + +# Show command output as escaped preformatted text for easier diagnosis. +error(text('systemd_eusercmd', + ui_tag('tt', html_escape($user)), + ui_tag('pre', html_escape($out)))); +} diff --git a/systemd/set_linger.cgi b/systemd/set_linger.cgi new file mode 100755 index 000000000..99606afd5 --- /dev/null +++ b/systemd/set_linger.cgi @@ -0,0 +1,50 @@ +#!/usr/local/bin/perl +# Toggle linger for a systemd user manager + +use strict; +use warnings; + +require './systemd-lib.pl'; ## no critic + +our (%access, %in, %text); + +# This page is reached from the index linger toggle links. +error_setup($text{'systemd_linger_err'}); +ReadParse(); + +# Validate the requested user and linger state before calling loginctl. +my $user = clean_unit_value($in{'user'}); +my $enabled = $in{'enabled'}; +get_user_details($user) || error($text{'systemd_euser'}); +systemd_can_linger(\%access, $user) || systemd_acl_error('plinger'); +if (!defined($enabled) || $enabled !~ /^[01]$/) { + error($text{'systemd_elinger'}); + } + +# Apply the requested linger state through loginctl. +my ($ok, $out) = set_user_linger($user, $enabled); +$ok || error_linger_command($user, $out); + +# Enabling linger should also bring up the user manager immediately. +if ($enabled) { + ($ok, $out) = start_user_manager($user); + $ok || error_linger_command($user, $out); + } + +# Record the change and return to the User units tab for the same owner. +webmin_log("linger", "systemd-user", $user, + { 'user' => $user, 'enabled' => $enabled }); +redirect(index_url(undef, 1, $user)); + +# error_linger_command(user, output) +# Shows escaped loginctl or systemctl output from a failed operation. +sub error_linger_command +{ +my ($user, $out) = @_; +$out ||= $text{'systemd_euser'}; + +# Show command output as escaped preformatted text for easier diagnosis. +error(text('systemd_eusercmd', + ui_tag('tt', html_escape($user)), + ui_tag('pre', html_escape($out)))); +} diff --git a/systemd/syslog_logs.pl b/systemd/syslog_logs.pl new file mode 100644 index 000000000..6b7cfdd8b --- /dev/null +++ b/systemd/syslog_logs.pl @@ -0,0 +1,24 @@ +# Supplies the System Logs module with a journalctl-backed log source. + +use strict; +use warnings; + +require 'systemd-lib.pl'; ## no critic + +our %text; + +# syslog_getlogs() +# Returns a journalctl log source if journalctl is installed. +sub syslog_getlogs +{ +if (has_command("journalctl")) { + # Let the System Logs module run journalctl when rendering entries. + return ( { 'cmd' => "journalctl -n 1000", + 'desc' => $text{'syslog_journalctl'}, + 'active' => 1, } ); + } +else { + # Without journalctl there is no useful systemd log source to add. + return ( ); + } +} diff --git a/systemd/systemd-lib.pl b/systemd/systemd-lib.pl new file mode 100644 index 000000000..cd003e504 --- /dev/null +++ b/systemd/systemd-lib.pl @@ -0,0 +1,3938 @@ +=head1 systemd-lib.pl + +Common functions for listing, creating and managing systemd system and user units. + +=cut + +use strict; +use warnings; +use lib ".."; +use Cwd qw(abs_path); +use Symbol qw(gensym); + +use WebminCore; + +our (%access, %config, %gconfig, %in, %text, @list_units_cache, $remote_user, + $module_var_directory); +our ($unit_config_change_flag, $daemon_reload_time_flag); + +init_config(); +%access = get_module_acl(); +$config{"desc"} = 1 if (!defined($config{"desc"})); +$config{"logs_lines"} = 200 + if (!defined($config{"logs_lines"}) || + $config{"logs_lines"} !~ /^\d+$/ || + $config{"logs_lines"} < 1); +$config{"logs_current_boot"} = 0 + if (!defined($config{"logs_current_boot"})); +$config{"show_runtime_units"} = 1 + if (!defined($config{"show_runtime_units"})); +$config{"default_create_scope"} = "system" + if (!defined($config{"default_create_scope"}) || + $config{"default_create_scope"} !~ /^(system|user)$/); +$config{"manual_vendor_units"} = 1 + if (!defined($config{"manual_vendor_units"})); +$config{"default_linger"} = 1 + if (!defined($config{"default_linger"})); +$config{"show_unit_suffixes"} = 0 + if (!defined($config{"show_unit_suffixes"})); +$config{"show_dropin_inventory"} = 1 + if (!defined($config{"show_dropin_inventory"})); +$config{"create_return_index"} = 0 + if (!defined($config{"create_return_index"}) || + $config{"create_return_index"} !~ /^[01]$/); +$config{"visible_tabs"} ||= default_visible_tabs(); +$unit_config_change_flag = $module_var_directory."/unit-change-flag"; +$daemon_reload_time_flag = $module_var_directory."/daemon-reload-flag"; + +=head2 systemd_acl_keys() + +Returns all boolean ACL keys understood by this module. + +=cut +sub systemd_acl_keys +{ +return (qw(view view_user status status_user logs logs_user + start start_user stop stop_user restart restart_user + boot boot_user mask mask_user + create create_user edit edit_user delete delete_user + dropin dropin_user manual manual_user reload linger backup)); +} + +=head2 systemd_user_unit_acl(user) + +Returns a safe ACL hash for managing one Unix user's systemd user units, +without granting any system-unit access. + +=cut +sub systemd_user_unit_acl +{ +my ($user) = @_; +my %acl = map { $_, 0 } systemd_acl_keys(); +foreach my $key (qw(view_user status_user logs_user start_user stop_user + restart_user boot_user create_user edit_user + delete_user dropin_user manual_user linger)) { + $acl{$key} = 1; + } +$acl{'noconfig'} = 1; +$acl{'mode'} = 1; +$acl{'users'} = defined($user) ? $user : ""; +$acl{'uidmin'} = ""; +$acl{'uidmax'} = ""; +return %acl; +} + +=head2 systemd_acl_bool(&acl, key) + +Returns a boolean ACL value. + +=cut +sub systemd_acl_bool +{ +my ($acl, $key) = @_; +$acl ||= \%access; +return $acl->{$key} ? 1 : 0 if (exists($acl->{$key})); +return 0; +} + +=head2 systemd_acl_error(reason-key) + +Throws a standardized ACL denial error. The key should be the suffix after +C, such as C or C. + +=cut +sub systemd_acl_error +{ +my ($reason) = @_; +my $prefix = $text{'eacl_np'} || "Access denied:"; +my $msg = $text{'eacl_'.$reason} || $text{'eacl_penter'} || + "Access to this systemd action is not permitted."; +error($prefix." ".$msg); +} + +=head2 systemd_acl_any(&acl, keys...) + +Returns 1 if any named ACL key is allowed. + +=cut +sub systemd_acl_any +{ +my ($acl, @keys) = @_; +foreach my $key (@keys) { + return 1 if (systemd_acl_bool($acl, $key)); + } +return 0; +} + +=head2 systemd_acl_user_allowed(&acl, user) + +Returns 1 if the ACL's Unix-user filter permits access to a systemd user +manager owned by C. The filter intentionally mirrors Cron's mode/users +ACL model so Virtualmin templates can grant per-owner access predictably. + +=cut +sub systemd_acl_user_allowed +{ +my ($acl, $user) = @_; +$acl ||= \%access; +return 0 if (!$user); +my $mode = defined($acl->{'mode'}) ? $acl->{'mode'} : 0; +$mode = 0 if ($mode !~ /^[0-5]$/); +if ($mode == 1 || $mode == 2) { + my %umap = map { $_, 1 } split(/\s+/, $acl->{'users'} || ""); + return 0 if ($mode == 1 && !$umap{$user}); + return 0 if ($mode == 2 && $umap{$user}); + return 1; + } +elsif ($mode == 3) { + return defined($remote_user) && $remote_user eq $user ? 1 : 0; + } +elsif ($mode == 4) { + my @uinfo = getpwnam($user); + my $uidmin = defined($acl->{'uidmin'}) ? $acl->{'uidmin'} : ""; + my $uidmax = defined($acl->{'uidmax'}) ? $acl->{'uidmax'} : ""; + return 0 if (!@uinfo); + return 0 if ($uidmin ne "" && $uinfo[2] < $uidmin); + return 0 if ($uidmax ne "" && $uinfo[2] > $uidmax); + return 1; + } +elsif ($mode == 5) { + my @uinfo = getpwnam($user); + return @uinfo && defined($acl->{'users'}) && + $uinfo[3] == $acl->{'users'} ? 1 : 0; + } +return 1; +} + +=head2 systemd_acl_default_user(&acl) + +Returns a safe default Unix owner for user-unit views when the ACL narrows the +user set to exactly one account, or to the current Webmin user. + +=cut +sub systemd_acl_default_user +{ +my ($acl) = @_; +$acl ||= \%access; +my $mode = defined($acl->{'mode'}) ? $acl->{'mode'} : 0; +$mode = 0 if ($mode !~ /^[0-5]$/); +if ($mode == 1) { + my @users = grep { $_ ne "" } split(/\s+/, $acl->{'users'} || ""); + return $users[0] + if (@users == 1 && systemd_acl_user_allowed($acl, $users[0])); + return; + } +elsif ($mode == 3) { + return $remote_user + if (defined($remote_user) && + systemd_acl_user_allowed($acl, $remote_user)); + return; + } +return; +} + +=head2 systemd_can_view_system(&acl) + +Returns 1 if the ACL allows seeing or acting on system-scope units. + +=cut +sub systemd_can_view_system +{ +my ($acl) = @_; +return systemd_acl_any($acl, qw(view status logs start stop restart boot mask + create edit delete dropin manual reload)); +} + +=head2 systemd_can_view_user_scope(&acl, [user]) + +Returns 1 if the ACL allows seeing or acting on user-scope units, optionally +constrained to a specific Unix owner. + +=cut +sub systemd_can_view_user_scope +{ +my ($acl, $user) = @_; +return 0 if (defined($user) && $user ne "" && + !systemd_acl_user_allowed($acl, $user)); +return systemd_acl_bool($acl, 'view_user') || + systemd_acl_any($acl, qw(status_user logs_user start_user stop_user + restart_user boot_user mask_user create_user + edit_user delete_user dropin_user manual_user + linger)); +} + +=head2 systemd_can_enter_module(&acl) + +Returns 1 if the ACL allows any interactive access to this module. + +=cut +sub systemd_can_enter_module +{ +my ($acl) = @_; +return systemd_can_view_system($acl) || systemd_can_view_user_scope($acl); +} + +=head2 systemd_can_view_scope(&acl, user-scope, [user]) + +Returns 1 if the ACL allows seeing or acting on the selected unit scope. + +=cut +sub systemd_can_view_scope +{ +my ($acl, $user_scope, $user) = @_; +return $user_scope ? systemd_can_view_user_scope($acl, $user) : + systemd_can_view_system($acl); +} + +=head2 systemd_can_inspect(&acl, user-scope, [user]) + +Returns 1 if status, properties or dependency inspection is allowed. + +=cut +sub systemd_can_inspect +{ +my ($acl, $user_scope, $user) = @_; +my $key = $user_scope ? 'status_user' : 'status'; +return systemd_acl_bool($acl, $key) && + systemd_can_view_scope($acl, $user_scope, $user) ? 1 : 0; +} + +=head2 systemd_can_logs(&acl, user-scope, [user]) + +Returns 1 if journal log inspection is allowed. + +=cut +sub systemd_can_logs +{ +my ($acl, $user_scope, $user) = @_; +my $key = $user_scope ? 'logs_user' : 'logs'; +return systemd_acl_bool($acl, $key) && + systemd_can_view_scope($acl, $user_scope, $user) ? 1 : 0; +} + +=head2 systemd_can_runtime(&acl, action, user-scope, [user]) + +Returns 1 if a runtime action such as C, C or C is +allowed for the selected scope. + +=cut +sub systemd_can_runtime +{ +my ($acl, $action, $user_scope, $user) = @_; +return 0 if ($action !~ /^(start|stop|restart)$/); +my $key = $user_scope ? $action.'_user' : $action; +return systemd_acl_bool($acl, $key) && + systemd_can_view_scope($acl, $user_scope, $user) ? 1 : 0; +} + +=head2 systemd_can_boot(&acl, user-scope, [user]) + +Returns 1 if enabling, disabling, masking or unmasking units is allowed. + +=cut +sub systemd_can_boot +{ +my ($acl, $user_scope, $user) = @_; +my $key = $user_scope ? 'boot_user' : 'boot'; +return systemd_acl_bool($acl, $key) && + systemd_can_view_scope($acl, $user_scope, $user) ? 1 : 0; +} + +=head2 systemd_can_mask(&acl, user-scope, [user]) + +Returns 1 if masking or unmasking units is allowed. + +=cut +sub systemd_can_mask +{ +my ($acl, $user_scope, $user) = @_; +my $key = $user_scope ? 'mask_user' : 'mask'; +return systemd_acl_bool($acl, $key) && + systemd_can_view_scope($acl, $user_scope, $user) ? 1 : 0; +} + +=head2 systemd_can_create(&acl, user-scope, [user]) + +Returns 1 if creating units in the selected scope is allowed. + +=cut +sub systemd_can_create +{ +my ($acl, $user_scope, $user) = @_; +my $key = $user_scope ? 'create_user' : 'create'; +return systemd_acl_bool($acl, $key) && + systemd_can_view_scope($acl, $user_scope, $user) ? 1 : 0; +} + +=head2 systemd_can_edit(&acl, user-scope, [user]) + +Returns 1 if editing a full unit file in the selected scope is allowed. + +=cut +sub systemd_can_edit +{ +my ($acl, $user_scope, $user) = @_; +my $key = $user_scope ? 'edit_user' : 'edit'; +return systemd_acl_bool($acl, $key) && + systemd_can_view_scope($acl, $user_scope, $user) ? 1 : 0; +} + +=head2 systemd_can_delete(&acl, user-scope, [user]) + +Returns 1 if deleting units in the selected scope is allowed. + +=cut +sub systemd_can_delete +{ +my ($acl, $user_scope, $user) = @_; +my $key = $user_scope ? 'delete_user' : 'delete'; +return systemd_acl_bool($acl, $key) && + systemd_can_view_scope($acl, $user_scope, $user) ? 1 : 0; +} + +=head2 systemd_can_dropin(&acl, user-scope, [user]) + +Returns 1 if managing drop-in overrides in the selected scope is allowed. + +=cut +sub systemd_can_dropin +{ +my ($acl, $user_scope, $user) = @_; +my $key = $user_scope ? 'dropin_user' : 'dropin'; +return systemd_acl_bool($acl, $key) && + systemd_can_view_scope($acl, $user_scope, $user) ? 1 : 0; +} + +=head2 systemd_can_manual(&acl, file-info) + +Returns 1 if the ACL permits manual editing for a file descriptor returned by +C. + +=cut +sub systemd_can_manual +{ +my ($acl, $info) = @_; +return 0 if (!$info || !$info->{'scope'}); +if ($info->{'scope'} eq 'user') { + return systemd_acl_bool($acl, 'manual_user') && + systemd_acl_user_allowed($acl, $info->{'user'}) ? 1 : 0; + } +return systemd_acl_bool($acl, 'manual') ? 1 : 0; +} + +=head2 systemd_can_linger(&acl, user) + +Returns 1 if managing linger for a Unix user is allowed. + +=cut +sub systemd_can_linger +{ +my ($acl, $user) = @_; +return systemd_acl_bool($acl, 'linger') && + systemd_acl_user_allowed($acl, $user) ? 1 : 0; +} + +=head2 systemd_can_reload(&acl) + +Returns 1 if reloading the system manager is allowed. + +=cut +sub systemd_can_reload +{ +my ($acl) = @_; +return systemd_acl_bool($acl, 'reload'); +} + +=head2 systemd_can_reload_user(&acl, user) + +Returns 1 if reloading a user's systemd manager is allowed. + +=cut +sub systemd_can_reload_user +{ +my ($acl, $user) = @_; +return 0 if (!systemd_can_view_user_scope($acl, $user)); +return systemd_acl_any($acl, qw(create_user edit_user delete_user + dropin_user manual_user boot_user + linger)) ? 1 : 0; +} + +=head2 list_units() + +Returns a list of all known systemd units managed by this module, each as a +hash ref with keys such as 'name', 'desc', 'unitstate', 'runtime', 'substate', +'pid' and 'file'. + +=cut +sub list_units +{ +if (@list_units_cache) { + return @list_units_cache; + } + +my $units_piped = join('|', get_unit_types()); +my $creatable_piped = join('|', get_creatable_unit_types()); +my $list_piped = join('|', get_list_unit_types()); +my $list_types = join(" ", map { "-t ".quotemeta($_) } + get_list_unit_types()); + +# Ask the running system manager for loaded units first. +my $out = backquote_command("systemctl list-units --full --all $list_types --no-legend"); +my $ex = $?; +my @units; +foreach my $l (split(/\r?\n/, $out)) { + $l =~ s/^[^a-z0-9\-\_\.]+//i; + my ($unit, $loaded, $active, $sub, $desc) = split(/\s+/, $l, 5); + if ($unit ne "UNIT" && $loaded eq "loaded") { + push(@units, $unit); + } + } +error("Failed to list systemd units : $out") if ($ex && @units < 10); + +# Also find unit files for units that may be disabled at boot and not running, +# and so don't show up in systemctl list-units. +my $local_root = get_unit_root(); +my $packaged_root = get_unit_root(undef, 1); +my @scan_roots = ( [ $local_root, $creatable_piped ] ); +push(@scan_roots, [ $packaged_root, $list_piped ]) + if ($packaged_root && $packaged_root ne $local_root); +foreach my $scan (@scan_roots) { + my ($root, $type_piped) = @$scan; + next if (!$root || !-d $root); + opendir(my $units_dh, $root) || next; + push(@units, grep { !/\.wants$/ && !/^\./ && !-d "$root/$_" && + /\.($type_piped)$/ } readdir($units_dh)); + closedir($units_dh); + } + +# Add unit files that may not appear in list-units. +$out = backquote_command("systemctl list-unit-files $list_types --no-legend"); +foreach my $l (split(/\r?\n/, $out)) { + if ($l =~ /^(\S+\.($units_piped))\s+\S+/ || + $l =~ /^(\S+)\s+\S+/) { + push(@units, $1); + } + } + +# Skip generated low-level units that are not useful outside the Devices tab. +@units = grep { !/^sys-devices-/ && + !/^\-\.mount/ && + !/^\-\.slice/ && + !/^systemd-/ } @units; +@units = unique(@units); + +# Template units are listed by systemd but cannot be managed directly. +@units = grep { !/\@$/ && !/\@\.($units_piped)$/ } @units; +@units = grep { valid_unit_name($_) } @units; + +# Dump unit state in batches to keep command lines at a safe length. +my %info; +my $ecount = 0; +while(@units) { + my @args; + while(@args < 100 && @units) { + push(@args, shift(@units)); + } + my $qargs = join(" ", map { quotemeta($_) } @args); + my $out = backquote_command("systemctl show --property=Id,Description,UnitFileState,ActiveState,SubState,ExecStart,ExecStop,ExecReload,ExecMainPID,FragmentPath,DropInPaths ".$qargs." 2>/dev/null"); + my @lines = split(/\r?\n/, $out); + my $curr; + my @units; + if (@lines) { + $curr = { }; + push(@units, $curr); + } + foreach my $l (@lines) { + if ($l eq "") { + # Start of a new unit section + $curr = { }; + push(@units, $curr); + } + else { + # A property in the current one + my ($n, $v) = split(/=/, $l, 2); + $curr->{$n} = $v; + } + } + foreach my $u (@units) { + $info{$u->{'Id'}} = $u if ($u->{'Id'}); + } + $ecount++ if ($?); + } +if ($ecount && keys(%info) < 2) { + error("Failed to read systemd units : ". + ui_tag('pre', html_escape($out))); + } + +# Convert systemctl properties into the compact row hashes used by the UI. +my @rv; +my %done; +foreach my $name (keys %info) { + my $root = get_unit_root($name); + my $i = $info{$name}; + my $file = $i->{'FragmentPath'}; + $file = $root."/".$name + if (!$file && $root && -f $root."/".$name); + next if ($i->{'Description'} =~ /^LSB:\s/); + push(@rv, { 'name' => $name, + 'desc' => $i->{'Description'}, + 'unitstate' => $i->{'UnitFileState'}, + 'runtime' => $i->{'ActiveState'}, + 'substate' => $i->{'SubState'}, + 'boot' => $i->{'UnitFileState'} eq 'enabled' ? 1 : + $i->{'UnitFileState'} eq 'static' ? 2 : + $i->{'UnitFileState'} eq 'masked' ? -1 : 0, + 'status' => $i->{'ActiveState'} eq 'active' ? 1 : 0, + 'start' => $i->{'ExecStart'}, + 'stop' => $i->{'ExecStop'}, + 'reload' => $i->{'ExecReload'}, + 'pid' => $i->{'ExecMainPID'}, + 'file' => $file, + }); + $done{$name}++; + } + +# Cache and return rows sorted by unit name. +@rv = sort { $a->{'name'} cmp $b->{'name'} } @rv; +@list_units_cache = @rv; +return @rv; +} + +=head2 start_unit(name) + +Starts a systemd unit and returns an OK flag and command output. + +=cut +sub start_unit +{ +my ($name) = @_; +return (0, $text{'systemd_ename'}) if (!valid_unit_name($name)); +my $out = backquote_logged( + "systemctl start ".quotemeta($name)." 2>&1 /dev/null"); + } +return (!$?, $out); +} + +=head2 stop_unit(name) + +Stops a systemd unit and returns an OK flag and command output. + +=cut +sub stop_unit +{ +my ($name) = @_; +return (0, $text{'systemd_ename'}) if (!valid_unit_name($name)); +my $out = backquote_logged( + "systemctl stop ".quotemeta($name)." 2>&1 &1 &1 &1 &1 &1 &1 &1 &1 &1 &1 / ? shell_exec_command($sh, $cmd) : $cmd; +} + +=head2 clean_unit_value(value) + +Returns a scalar systemd unit value with line breaks removed. + +=cut +sub clean_unit_value +{ +my ($value) = @_; +return if (!defined($value)); +$value =~ s/\r|\n/ /g; +$value =~ s/\0//g; +$value =~ s/^\s+//; +$value =~ s/\s+$//; +return $value; +} + +=head2 clean_unit_body(value) + +Returns multi-line systemd unit directives with nulls and carriage returns +removed. + +=cut +sub clean_unit_body +{ +my ($value) = @_; +return if (!defined($value)); +$value =~ s/\r//g; +$value =~ s/\0//g; +$value =~ s/^\s+//; +$value =~ s/\s+$//; +return $value; +} + +=head2 quote_unit_word(value) + +Returns a quoted systemd unit word with quotes and backslashes escaped. + +=cut +sub quote_unit_word +{ +my ($value) = @_; +$value =~ s/\\/\\\\/g; +$value =~ s/"/\\"/g; +return "\"$value\""; +} + +=head2 split_environment_assignments(value) + +Splits Environment= input into assignments, allowing quoted values after the +NAME= prefix. + +=cut +sub split_environment_assignments +{ +my ($value) = @_; +my @rv; +my $current = ""; +my $quote = ""; +for(my $i = 0; $i < length($value); $i++) { + my $ch = substr($value, $i, 1); + if ($quote) { + if ($ch eq "\\" && $quote eq '"' && $i + 1 < length($value)) { + $current .= substr($value, ++$i, 1); + } + elsif ($ch eq $quote) { + $quote = ""; + } + else { + $current .= $ch; + } + } + elsif ($ch eq '"' || $ch eq "'") { + $quote = $ch; + } + elsif ($ch =~ /\s/) { + if (length($current)) { + push(@rv, $current); + $current = ""; + } + } + else { + $current .= $ch; + } + } +push(@rv, $current) if (length($current)); +return @rv; +} + +=head2 format_environment_directives(value) + +Returns Environment= lines for a user-entered set of environment variables. + +=cut +sub format_environment_directives +{ +my ($value) = @_; +$value = clean_unit_value($value); +$value = "" if (!defined($value)); +return ( ) if ($value !~ /\S/); + +# Preserve quoted variable values while still allowing several NAME=VALUE +# words to become separate Environment= directives. +my @vars = split_environment_assignments($value); +@vars = ( $value ) if (!@vars); +return map { "Environment=".quote_unit_word($_)."\n" } @vars; +} + +=head2 format_output_value(value) + +Returns a StandardOutput/StandardError value, appending to absolute files. + +=cut +sub format_output_value +{ +my ($value) = @_; +$value = clean_unit_value($value); +$value = "" if (!defined($value)); +return if ($value !~ /\S/); +return $value =~ /^\// ? "append:$value" : $value; +} + +=head2 valid_duration(value) + +Returns 1 if a value matches systemd's duration syntax used by timeout fields. + +=cut +sub valid_duration +{ +my ($value) = @_; +my $unit = qr/usec|us|msec|ms|seconds?|sec|s|minutes?|min|m|hours?|hr|h|days?|d|weeks?|w|months?|M|years?|y/i; +$value =~ s/^\s+//; +$value =~ s/\s+$//; +return 1 if ($value =~ /^infinity$/i); +return 0 if ($value !~ /\S/); +while ($value =~ /\G\s*\d+(?:\.\d+)?\s*(?:$unit)?/gc) { + } +return defined(pos($value)) && pos($value) == length($value); +} + +=head2 valid_path(value, allow-dash, allow-tilde, allow-plus) + +Returns 1 if a unit-file path option is absolute or explicitly allowed. + +=cut +sub valid_path +{ +my ($value, $allow_dash, $allow_tilde, $allow_plus) = @_; +return 0 if (!defined($value)); +$value =~ s/^\s+//; +$value =~ s/\s+$//; +$value =~ s/^-// if ($allow_dash); +$value =~ s/^\+// if ($allow_plus); +return 0 if ($value =~ /[\r\n\0=\s]/); +return 1 if ($value =~ /^\//); +return 1 if ($allow_tilde && $value =~ /^~/); +return 0; +} + +=head2 path_unit_name(path, type) + +Returns the canonical systemd unit name for a mount-like path and unit type. + +=cut +sub path_unit_name +{ +my ($path, $type) = @_; +$type ||= "mount"; +$path = clean_unit_value($path); +return if (!$path || !valid_path($path, 0, 0, 0)); +return if ($type !~ /^(mount|automount)$/); + +# Prefer systemd's own escaping when it is available. The fallback supports the +# common ASCII path names used by Webmin's structured mount forms. +my $escape = has_command("systemd-escape"); +if ($escape) { + my $cmd = quotemeta($escape)." --path --suffix=".quotemeta($type)." ". + quotemeta($path)." 2>/dev/null"; + my $out = backquote_command($cmd); + $out =~ s/\r//g; + $out =~ s/\s+$//; + return $out if (valid_creatable_unit_name($out)); + } + +my $name = $path; +$name =~ s{/+}{/}g; +$name =~ s{/$}{} if ($name ne "/"); +$name = $name eq "/" ? "-" : substr($name, 1); +return if ($name =~ /[^A-Za-z0-9_.:@\/-]/); +$name =~ s{/}{-}g; +$name .= ".".$type; +return valid_creatable_unit_name($name) ? $name : undef; +} + +=head2 mount_where_from_data(data) + +Returns the Where= path from a rendered mount unit body. + +=cut +sub mount_where_from_data +{ +my ($data) = @_; +return if (!defined($data)); +my $section = ""; +foreach my $line (split(/\n/, $data)) { + $line =~ s/\r$//; + if ($line =~ /^\s*\[([^\]]+)\]\s*$/) { + $section = lc($1); + next; + } + next if ($section ne "mount" || $line =~ /^\s*[#;]/); + if ($line =~ /^\s*Where\s*=\s*(.*?)\s*$/) { + my $where = clean_unit_value($1); + return $where if ($where && valid_path($where, 0, 0, 0)); + } + } +return; +} + +=head2 mount_unit_where(unit, [user]) + +Returns the Where= path for an existing mount unit file. + +=cut +sub mount_unit_where +{ +my ($unit, $user) = @_; +return if (!ref($unit) || $unit->{'name'} !~ /\.mount$/); +my $file = $unit->{'file'}; +return if (!$file); +my $data; +if ($unit->{'user_scope'} || $user) { + my $owner = $user || $unit->{'user'}; + $data = read_user_unit_file($owner, $file) if ($owner); + } +elsif ($file !~ /\0/ && -r $file) { + $data = read_file_contents($file); + } +return mount_where_from_data($data); +} + +=head2 render_directive_body(directives) + +Returns directive lines from C<[ name, value ]> pairs, skipping blank values. + +=cut +sub render_directive_body +{ +my ($directives) = @_; +my $body = ""; +foreach my $row (@$directives) { + my $value = clean_unit_value($row->[1]); + $body .= $row->[0]."=$value\n" if ($value && $value =~ /\S/); + } +return $body; +} + +=head2 render_timer_body(options) + +Returns the [Timer] body generated from structured timer fields. + +=cut +sub render_timer_body +{ +my ($opts) = @_; +$opts = { } if (!ref($opts)); +return render_directive_body([ + [ 'OnCalendar', $opts->{'oncalendar'} ], + [ 'OnBootSec', $opts->{'onbootsec'} ], + [ 'OnUnitActiveSec', $opts->{'onunitactivesec'} ], + [ 'Persistent', $opts->{'persistent'} ? 'yes' : undef ], + [ 'RandomizedDelaySec', $opts->{'randomizeddelaysec'} ], + [ 'AccuracySec', $opts->{'accuracysec'} ], + [ 'Unit', $opts->{'unit'} ], + ]); +} + +=head2 render_socket_body(options) + +Returns the [Socket] body generated from structured socket fields. + +=cut +sub render_socket_body +{ +my ($opts) = @_; +$opts = { } if (!ref($opts)); +return render_directive_body([ + [ 'ListenStream', $opts->{'listenstream'} ], + [ 'ListenDatagram', $opts->{'listendatagram'} ], + [ 'ListenFIFO', $opts->{'listenfifo'} ], + [ 'Accept', $opts->{'accept'} ? 'yes' : undef ], + [ 'SocketUser', $opts->{'user'} ], + [ 'SocketGroup', $opts->{'group'} ], + [ 'SocketMode', $opts->{'mode'} ], + [ 'Service', $opts->{'service'} ], + ]); +} + +=head2 render_path_body(options) + +Returns the [Path] body generated from structured path fields. + +=cut +sub render_path_body +{ +my ($opts) = @_; +$opts = { } if (!ref($opts)); +return render_directive_body([ + [ 'PathExists', $opts->{'exists'} ], + [ 'PathExistsGlob', $opts->{'existsglob'} ], + [ 'PathChanged', $opts->{'changed'} ], + [ 'PathModified', $opts->{'modified'} ], + [ 'DirectoryNotEmpty', $opts->{'directorynotempty'} ], + [ 'MakeDirectory', $opts->{'makedirectory'} ? 'yes' : undef ], + [ 'Unit', $opts->{'unit'} ], + ]); +} + +=head2 render_mount_body(what, where, type, options) + +Returns the [Mount] body generated from structured mount fields. + +=cut +sub render_mount_body +{ +my ($what, $where, $type, $options) = @_; +return render_directive_body([ + [ 'What', $what ], + [ 'Where', $where ], + [ 'Type', $type ], + [ 'Options', $options ], + ]); +} + +=head2 render_automount_body(where, timeout-idle, directory-mode) + +Returns the [Automount] body generated from structured automount fields. + +=cut +sub render_automount_body +{ +my ($where, $idle, $mode) = @_; +return render_directive_body([ + [ 'Where', $where ], + [ 'TimeoutIdleSec', $idle ], + [ 'DirectoryMode', $mode ], + ]); +} + +=head2 render_swap_body(options) + +Returns the [Swap] body generated from structured swap fields. + +=cut +sub render_swap_body +{ +my ($opts) = @_; +$opts = { } if (!ref($opts)); +return render_directive_body([ + [ 'What', $opts->{'what'} ], + [ 'Priority', $opts->{'priority'} ], + [ 'Options', $opts->{'options'} ], + [ 'TimeoutSec', $opts->{'timeoutsec'} ], + ]); +} + +=head2 render_slice_body(options) + +Returns the [Slice] body generated from structured resource-control fields. + +=cut +sub render_slice_body +{ +my ($opts) = @_; +$opts = { } if (!ref($opts)); +return render_directive_body([ + [ 'CPUWeight', $opts->{'cpuweight'} ], + [ 'MemoryMax', $opts->{'memorymax'} ], + [ 'TasksMax', $opts->{'tasksmax'} ], + [ 'IOWeight', $opts->{'ioweight'} ], + ]); +} + +=head2 valid_output(value) + +Returns 1 if a StandardOutput/StandardError value is a safe systemd target. + +=cut +sub valid_output +{ +my ($value) = @_; +return 0 if ($value =~ /[\r\n\0=\s]/); +return 1 if ($value =~ /^\//); +return 1 if ($value =~ /^(inherit|null|tty|journal|kmsg|journal\+console|kmsg\+console|socket|fd:[A-Za-z0-9_.:-]+|file:\/\S+|append:\/\S+|truncate:\/\S+)$/); +return 0; +} + +=head2 clean_unit_options(options, [command-keys]) + +Returns a cleaned copy of a unit options hash. Values named in command-keys +keep line breaks because they later become one Exec*= directive per line. + +=cut +sub clean_unit_options +{ +my ($opts, $command_keys) = @_; +my %commands = map { $_, 1 } ref($command_keys) ? @$command_keys : ( ); +my %cleanopts; +if (ref($opts)) { + foreach my $o (keys(%$opts)) { + $cleanopts{$o} = $commands{$o} ? + clean_unit_body($opts->{$o}) : + clean_unit_value($opts->{$o}); + } + } +return \%cleanopts; +} + +=head2 render_unit_directives(options) + +Returns common [Unit] dependency directives from a cleaned options hash. + +=cut +sub render_unit_directives +{ +my ($opts) = @_; +my $data = ""; +foreach my $d ( + [ 'before', 'Before' ], + [ 'after', 'After' ], + [ 'wants', 'Wants' ], + [ 'requires', 'Requires' ], + [ 'conflicts', 'Conflicts' ], + [ 'onfailure', 'OnFailure' ], + [ 'onsuccess', 'OnSuccess' ], + ) { + my ($key, $directive) = @$d; + $data .= "$directive=$opts->{$key}\n" if ($opts->{$key}); + } +return $data; +} + +=head2 render_service_section(service, options) + +Returns the [Service] section for a systemd service unit spec. + +=cut +sub render_service_section +{ +my ($service, $opts) = @_; +$service = { } if (!ref($service)); +my $sh = has_command("sh") || "sh"; +my $pidfile = clean_unit_value($service->{'pidfile'}); +my @starts = split_exec_commands($service->{'start'}); +my @stops = split_exec_commands($service->{'stop'}); +my @reloads = split_exec_commands($service->{'reload'}); +my $remain = $service->{'remain'}; +my $service_type = ref($opts) ? $opts->{'type'} : undef; +$service_type ||= $remain ? 'oneshot' : undef; + +# Multiple startup commands need oneshot semantics unless an explicit type was +# chosen. For other types, run them through one shell command. +my $multi_start_oneshot = @starts > 1 && !$service_type; +my $start_type = $service_type || ($multi_start_oneshot ? 'oneshot' : undef); +if (@starts > 1 && $start_type && $start_type ne 'oneshot') { + @starts = (shell_exec_command($sh, join("; ", @starts))); + } +else { + @starts = map { format_exec_command($sh, $_) } @starts; + } +@stops = map { format_exec_command($sh, $_) } @stops; +@reloads = map { format_exec_command($sh, $_) } @reloads; +my (@startpres, @startposts, @stopposts); +if (ref($opts)) { + @startpres = map { format_exec_command($sh, $_) } + split_exec_commands($opts->{'startpre'}); + @startposts = map { format_exec_command($sh, $_) } + split_exec_commands($opts->{'startpost'}); + @stopposts = map { format_exec_command($sh, $_) } + split_exec_commands($opts->{'stoppost'}); + } +$service_type = 'oneshot' if ($multi_start_oneshot); +my $data = "\n[Service]\n"; +$data .= "Type=$service_type\n" if ($service_type); +foreach my $startpre (@startpres) { + $data .= "ExecStartPre=$startpre\n"; + } +foreach my $start (@starts) { + $data .= "ExecStart=$start\n"; + } +foreach my $startpost (@startposts) { + $data .= "ExecStartPost=$startpost\n"; + } +foreach my $stop (@stops) { + $data .= "ExecStop=$stop\n"; + } +foreach my $stoppost (@stopposts) { + $data .= "ExecStopPost=$stoppost\n"; + } +foreach my $reload (@reloads) { + $data .= "ExecReload=$reload\n"; + } +$data .= "RemainAfterExit=yes\n" if ($remain); +$data .= "PIDFile=$pidfile\n" if ($pidfile); + +# Optional [Service] directives from the advanced creation form. +if (ref($opts)) { + foreach my $env (format_environment_directives($opts->{'env'})) { + $data .= $env; + } + $data .= "EnvironmentFile=$opts->{'envfile'}\n" if ($opts->{'envfile'}); + $data .= "User=$opts->{'user'}\n" if ($opts->{'user'}); + $data .= "Group=$opts->{'group'}\n" if ($opts->{'group'}); + $data .= "KillMode=$opts->{'killmode'}\n" if ($opts->{'killmode'}); + $data .= "WorkingDirectory=$opts->{'workdir'}\n" if ($opts->{'workdir'}); + $data .= "Restart=$opts->{'restart'}\n" if ($opts->{'restart'}); + $data .= "RestartSec=$opts->{'restartsec'}\n" if ($opts->{'restartsec'}); + $data .= "WatchdogSec=$opts->{'watchdogsec'}\n" if ($opts->{'watchdogsec'}); + + # timeout remains accepted as a historical alias for TimeoutStartSec. + my $timeoutstartsec = $opts->{'timeoutstartsec'} || $opts->{'timeout'}; + $data .= "TimeoutStartSec=$timeoutstartsec\n" if ($timeoutstartsec); + $data .= "TimeoutStopSec=$opts->{'timeoutstopsec'}\n" + if ($opts->{'timeoutstopsec'}); + $data .= "LimitNOFILE=$opts->{'limitnofile'}\n" if ($opts->{'limitnofile'}); + my $logout = format_output_value($opts->{'logstd'}); + my $logerr = format_output_value($opts->{'logerr'}); + $data .= "StandardOutput=$logout\n" if ($logout); + $data .= "StandardError=$logerr\n" if ($logerr); + $data .= "SyslogIdentifier=$opts->{'syslogid'}\n" if ($opts->{'syslogid'}); + $data .= "NoNewPrivileges=yes\n" if ($opts->{'nonewprivs'}); + $data .= "PrivateTmp=yes\n" if ($opts->{'privatetmp'}); + $data .= "ProtectSystem=$opts->{'protectsystem'}\n" if ($opts->{'protectsystem'}); + $data .= "ReadWritePaths=$opts->{'readwritepaths'}\n" if ($opts->{'readwritepaths'}); + } + +return $data; +} + +=head2 render_typed_section(type, body) + +Returns the type-specific section for a non-service systemd unit spec. + +=cut +sub render_typed_section +{ +my ($type, $body) = @_; +my $section = get_unit_section($type); +$body = clean_unit_body($body); +$body = "" if (!defined($body)); +my $data = ""; + +# The UI accepts only the body of the type-specific section; wrap it here so +# users do not need to type [Timer], [Socket], and so on. +if ($section && $body =~ /\S/) { + $data .= "\n[$section]\n"; + $data .= $body; + $data .= "\n" if ($body !~ /\n$/); + } + +return $data; +} + +=head2 render_install_section(type, options) + +Returns the [Install] section for a unit when it has a target. + +=cut +sub render_install_section +{ +my ($type, $opts) = @_; +my $wantedby = $opts->{'wantedby'}; +$wantedby ||= "multi-user.target" if ($type eq "service"); +return "" if (!$wantedby); +return "\n[Install]\nWantedBy=$wantedby\n"; +} + +=head2 render_unit(unit) + +Returns complete unit-file contents for a systemd unit spec hash. The hash +must include type and description, plus either service details or a body. + +=cut +sub render_unit +{ +my ($unit) = @_; +$unit = { } if (!ref($unit)); +my $type = $unit->{'type'} || "service"; +my @command_opts = $type eq "service" ? + ( 'startpre', 'startpost', 'stoppost' ) : ( ); +my $opts = clean_unit_options($unit->{'options'}, \@command_opts); +my $desc = clean_unit_value($unit->{'description'}); +my $data = "[Unit]\n"; +$data .= "Description=$desc\n" if ($desc); +$data .= render_unit_directives($opts); +if ($type eq "service") { + $data .= render_service_section($unit->{'service'}, $opts); + } +else { + $data .= render_typed_section($type, $unit->{'body'}); + } +$data .= render_install_section($type, $opts); +return $data; +} + +=head2 write_unit_file(file, data) + +Writes rendered systemd unit-file contents to disk. + +=cut +sub write_unit_file +{ +my ($cfile, $data) = @_; +my $cfile_fh = gensym(); +open_lock_tempfile($cfile_fh, ">$cfile"); +print_tempfile($cfile_fh, $data); +close_tempfile($cfile_fh); +} + +=head2 create_system_unit(name, data) + +Creates a system unit from rendered unit-file contents. + +=cut +sub create_system_unit +{ +my ($name, $data) = @_; +return (0, $text{'systemd_ename'}) if (!valid_creatable_unit_name($name)); +my $cfile = get_unit_root($name)."/".$name; +my ($vok, $vout) = verify_unit_data($cfile, $data, 0); +return (0, $vout) if (!$vok); +write_unit_file($cfile, $data); +reload_manager(); +return (1, ""); +} + +=head2 get_user_details(user) + +Returns user account details needed for per-user systemd units. + +=cut +sub get_user_details +{ +my ($user) = @_; +return if (!$user || $user =~ /[\0\r\n\/]/); +my @uinfo = getpwnam($user); +return if (!@uinfo || $uinfo[7] !~ /^\//); +return { 'user' => $uinfo[0], + 'uid' => $uinfo[2], + 'gid' => $uinfo[3], + 'home' => $uinfo[7] }; +} + +=head2 get_user_root(user) + +Returns the base directory for a user's systemd unit config files. + +=cut +sub get_user_root +{ +my ($user) = @_; +my $uinfo = get_user_details($user); +return if (!$uinfo); +return $uinfo->{'home'}."/.config/systemd/user"; +} + +=head2 valid_unit_name(name) + +Returns 1 if a systemd unit name is safe to pass to systemctl or use for +drop-in file management. + +=cut +sub valid_unit_name +{ +my ($name) = @_; +my $units_piped = join('|', get_unit_types()); +return 0 if ($name =~ /\@$/ || $name =~ /\@\.($units_piped)$/i); +return $name && $name =~ /^[a-z0-9\.\_\-\@:]+\.($units_piped)$/i; +} + +=head2 valid_creatable_unit_name(name, [user-scope]) + +Returns 1 if a systemd unit name is safe for creating a persistent unit file. + +=cut +sub valid_creatable_unit_name +{ +my ($name, $user_scope) = @_; +my $units_piped = join('|', get_creatable_unit_types($user_scope)); +return 0 if ($name =~ /\@$/ || $name =~ /\@\.($units_piped)$/i); +return $name && $name =~ /^[a-z0-9\.\_\-\@:]+\.($units_piped)$/i; +} + +=head2 valid_unit_file_name(name) + +Returns 1 if a filename looks like a direct systemd unit file. Unlike +C, this accepts all known unit suffixes and template files +because it is used only by the manual file editor. + +=cut +sub valid_unit_file_name +{ +my ($name) = @_; +my $units_piped = join('|', get_unit_types()); +return $name && $name !~ /[\0\r\n\/]/ && $name !~ /^\./ && + $name =~ /^[a-z0-9\.\_\-\@:]+\.($units_piped)$/i; +} + +=head2 verify_unit_data(file, data, [user-scope], [user]) + +Runs C against unit-file contents before a manual save. + +=cut +sub verify_unit_data +{ +my ($file, $data, $user_scope, $user) = @_; +my $analyze = has_command("systemd-analyze"); +return (1, undef) if (!$analyze); +return (0, $text{'systemd_econf'}) if (!defined($data)); +my $name = $file; +$name =~ s/^.*\///; +return (0, $text{'systemd_ename'}) if (!valid_unit_file_name($name)); +my $uinfo = $user_scope && $user ? get_user_details($user) : undef; +my $bad_user = text('systemd_everify', + ui_tag('tt', html_escape($text{'systemd_euser'}))); + +# Verify a temporary file with the real unit basename so systemd checks the +# correct unit type without touching the currently installed file. +my $tmpdir = tempname("systemd-verify-$$-".int(rand(1000000))); +make_dir($tmpdir, oct("0700")) || + return (0, text('systemd_everify', + ui_tag('tt', html_escape($!)))); +my $tmpfile = $tmpdir."/".$name; +my $write_ok = eval { + open(my $tmp_fh, ">", $tmpfile) || die "$tmpfile: $!"; + print {$tmp_fh} $data; + close($tmp_fh) || die "$tmpfile: $!"; + if ($uinfo) { + set_ownership_permissions( + $uinfo->{'uid'}, $uinfo->{'gid'}, oct("0700"), $tmpdir); + set_ownership_permissions( + $uinfo->{'uid'}, $uinfo->{'gid'}, oct("0600"), $tmpfile); + } + 1; + }; +if (!$write_ok) { + my $err = $@ || $!; + unlink($tmpfile); + rmdir($tmpdir); + return (0, text('systemd_everify', + ui_tag('tt', html_escape($err)))); + } + +# User units have slightly different directive rules, so verify them through +# the target user's manager environment when the owner is known. +my $cmd = $uinfo ? + user_manager_command($user, quotemeta($analyze), "--user", + "verify", quotemeta($tmpfile)) : + quotemeta($analyze)." ".($user_scope ? "--user " : ""). + "verify ".quotemeta($tmpfile); +return (0, $bad_user) if (!$cmd); +$cmd .= " 2>&1 {'home'}."/.config", + $uinfo->{'home'}."/.config/systemd", + $uinfo->{'home'}."/.config/systemd/user") { + next if (!-e $dir && !-l $dir); + return 0 if (!user_unit_dir_safe($uinfo, $dir)); + } +return 1; +} + +=head2 user_unit_dir_safe(user-info, dir) + +Returns 1 if a systemd user-unit directory is an existing directory owned by +the target Unix user, or if it does not exist yet. + +=cut +sub user_unit_dir_safe +{ +my ($uinfo, $dir) = @_; +return 0 if (!$uinfo); +return 1 if (!-e $dir && !-l $dir); +return 0 if (-l $dir || !-d $dir); +my @st = lstat($dir); +return 0 if (!@st); +return $st[4] == $uinfo->{'uid'} ? 1 : 0; +} + +=head2 check_user_unit_dirs(user) + +Returns an OK flag and error message after checking the user's systemd unit +directory tree for unsafe or wrongly-owned directories. + +=cut +sub check_user_unit_dirs +{ +my ($user) = @_; +my $uinfo = get_user_details($user); +return (0, $text{'systemd_euser'}) if (!$uinfo); + +# Check the fixed parent directories first. +foreach my $dir ($uinfo->{'home'}."/.config", + $uinfo->{'home'}."/.config/systemd", + $uinfo->{'home'}."/.config/systemd/user") { + if (!user_unit_dir_safe($uinfo, $dir)) { + return (0, $text{'systemd_euserunitdir'}); + } + } + +# systemctl --user enable/disable writes below *.wants and *.requires +# directories. Existing drop-in directories are checked too. +my $root = get_user_root($user); +if ($root && -d $root && !-l $root) { + opendir(my $dh, $root) || return (0, $text{'systemd_euserunitdir'}); + foreach my $entry (readdir($dh)) { + next if ($entry !~ /\.(?:wants|requires|d)$/); + my $dir = $root."/".$entry; + if (!user_unit_dir_safe($uinfo, $dir)) { + closedir($dh); + return (0, $text{'systemd_euserunitdir'}); + } + } + closedir($dh); + } +return (1, undef); +} + +=head2 user_unit_file_safe(user, file, [must-exist]) + +Returns 1 if a user unit file is a direct, non-symlinked file below the +user's systemd unit config directory. + +=cut +sub user_unit_file_safe +{ +my ($user, $file, $must_exist) = @_; +my $root = get_user_root($user); +return 0 if (!$root || !$file || $file =~ /[\0\r\n]/); +return 0 if (!user_root_safe($user)); + +# Only direct child unit files are managed. This prevents path traversal and +# avoids following user-created subdirectories or symlinks. +return 0 if ($file !~ /^\Q$root\E\/([^\/]+)$/); +my $unit = $1; +return 0 if (!valid_unit_name($unit)); +return 0 if (-l $file); +return $must_exist ? -f $file : (!-e $file || -f $file); +} + +=head2 read_user_unit_file(user, file) + +Reads a user unit file as the owning Unix user after path validation. + +=cut +sub read_user_unit_file +{ +my ($user, $file) = @_; +return if (!user_unit_file_safe($user, $file, 1)); +return eval_as_unix_user($user, sub { + return read_file_contents($file); + }); +} + +=head2 write_user_unit_file(user, file, data) + +Writes a user unit file as the owning Unix user after path validation. + +=cut +sub write_user_unit_file +{ +my ($user, $file, $data) = @_; +return (0, $text{'systemd_euserunitfile'}) + if (!user_unit_file_safe($user, $file, 0)); +return (1, undef) if (is_readonly_mode()); +my $ok = eval { + # Drop privileges for the actual write so a race cannot make root write + # through a user-controlled symlink. + eval_as_unix_user($user, sub { + die $text{'systemd_euserunitfile'} + if (-l $file || (-e $file && !-f $file)); + my $userunit_fh = gensym(); + open_lock_tempfile($userunit_fh, ">$file"); + print_tempfile($userunit_fh, $data); + close_tempfile($userunit_fh); + set_ownership_permissions(undef, undef, oct("0644"), $file); + }); + 1; + }; +my $err = $@; +$err =~ s/\s+at\s+(\/\S+)\s+line\s+(\d+)\.?// if ($err); +return $ok ? (1, undef) : (0, $err || $text{'systemd_euserunitfile'}); +} + +=head2 verify_dropin_data(unit-file, unit-data, dropin-data, [user-scope], [unit-state], [user]) + +Runs C against a unit plus an override drop-in. +Transient units are skipped because they are not normal file-backed units, and +systemd-analyze cannot reliably load their temporary copies by name. + +=cut +sub verify_dropin_data +{ +my ($file, $unit_data, $dropin_data, $user_scope, $unitstate, $user) = @_; +my $analyze = has_command("systemd-analyze"); +return (1, undef) if (!$analyze); +return (0, $text{'systemd_econf'}) + if (!defined($unit_data) || !defined($dropin_data)); +my $name = $file; +$name =~ s/^.*\///; +return (0, $text{'systemd_ename'}) if (!valid_unit_file_name($name)); +return (1, undef) + if (dropin_verify_unsupported($file, $unitstate)); +my $uinfo = $user_scope && $user ? get_user_details($user) : undef; +my $bad_user = text('systemd_everify', + ui_tag('tt', html_escape($text{'systemd_euser'}))); + +# Recreate the target unit and its drop-in in a temporary tree so verify sees +# the same shape systemd will load, without touching the installed files. +my $tmpdir = tempname("systemd-dropin-verify-$$-".int(rand(1000000))); +make_dir($tmpdir, oct("0700")) || + return (0, text('systemd_everify', + ui_tag('tt', html_escape($!)))); +my $tmpfile = $tmpdir."/".$name; +my $dropdir = $tmpfile.".d"; +my $dropfile = $dropdir."/override.conf"; +my $write_ok = eval { + open(my $unit_fh, ">", $tmpfile) || die "$tmpfile: $!"; + print {$unit_fh} $unit_data; + close($unit_fh) || die "$tmpfile: $!"; + make_dir($dropdir, oct("0700")) || die "$dropdir: $!"; + open(my $drop_fh, ">", $dropfile) || die "$dropfile: $!"; + print {$drop_fh} $dropin_data; + close($drop_fh) || die "$dropfile: $!"; + if ($uinfo) { + foreach my $dir ($tmpdir, $dropdir) { + set_ownership_permissions( + $uinfo->{'uid'}, $uinfo->{'gid'}, + oct("0700"), $dir); + } + foreach my $file ($tmpfile, $dropfile) { + set_ownership_permissions( + $uinfo->{'uid'}, $uinfo->{'gid'}, + oct("0600"), $file); + } + } + 1; + }; +if (!$write_ok) { + my $err = $@ || $!; + unlink($dropfile); + rmdir($dropdir); + unlink($tmpfile); + rmdir($tmpdir); + return (0, text('systemd_everify', + ui_tag('tt', html_escape($err)))); + } + +my $cmd = $uinfo ? + user_manager_command($user, quotemeta($analyze), "--user", + "verify", quotemeta($tmpfile)) : + quotemeta($analyze)." ".($user_scope ? "--user " : ""). + "verify ".quotemeta($tmpfile); +return (0, $bad_user) if (!$cmd); +$cmd .= " 2>&1 . + +=cut +sub dropin_verify_unsupported +{ +my ($file, $unitstate) = @_; +return 1 if (defined($unitstate) && $unitstate eq 'transient'); +return 1 if (defined($file) && $file =~ m{/systemd/transient/}); +return 0; +} + +=head2 system_dropin_file(unit) + +Returns the standard local drop-in override file for a system unit. + +=cut +sub system_dropin_file +{ +my ($unit) = @_; +return if (!valid_unit_name($unit)); +return "/etc/systemd/system/$unit.d/override.conf"; +} + +=head2 read_system_dropin_file(unit) + +Reads the standard system drop-in override file, if it exists and is safe. + +=cut +sub read_system_dropin_file +{ +my ($unit) = @_; +my $file = system_dropin_file($unit); +return if (!$file); +my $dir = $file; +$dir =~ s{/[^/]+$}{}; +return if (-l $dir || (-e $dir && !-d $dir)); +return "" if (!-e $file); +return if (-l $file || !-f $file); +lock_file($file); +my $data = read_file_contents($file); +unlock_file($file); +return $data; +} + +=head2 dropin_exists(user-scope, user, unit) + +Returns 1 if the standard drop-in override file exists and is safe to open. + +=cut +sub dropin_exists +{ +my ($user_scope, $user, $unit) = @_; +if ($user_scope) { + my $file = user_dropin_file($user, $unit); + return $file && user_dropin_file_safe($user, $file, 1) ? 1 : 0; + } +my $file = system_dropin_file($unit); +return 0 if (!$file); +my $dir = $file; +$dir =~ s{/[^/]+$}{}; +return 0 if (-l $dir || (-e $dir && !-d $dir)); +return -f $file && !-l $file ? 1 : 0; +} + +=head2 write_system_dropin_file(unit, data) + +Writes the standard local drop-in override file for a system unit. + +=cut +sub write_system_dropin_file +{ +my ($unit, $data) = @_; +my $file = system_dropin_file($unit); +return (0, $text{'systemd_ename'}) if (!$file); +my $dir = $file; +$dir =~ s{/[^/]+$}{}; +return (0, $text{'systemd_edropinfile'}) + if (-l $dir || (-e $dir && !-d $dir)); +return (1, undef) if (is_readonly_mode()); +if (!-d $dir) { + make_dir($dir, oct("0755"), 0) || + return (0, "$dir: $!"); + } +return (0, $text{'systemd_edropinfile'}) + if (-l $dir || !-d $dir || -l $file || + (-e $file && !-f $file)); +lock_file($file); +my $fh = gensym(); +open_tempfile($fh, ">$file"); +print_tempfile($fh, defined($data) ? $data : ""); +close_tempfile($fh); +unlock_file($file); +return (1, undef); +} + +=head2 delete_system_dropin_file(unit) + +Deletes the standard local drop-in override file for a system unit. + +=cut +sub delete_system_dropin_file +{ +my ($unit) = @_; +my $file = system_dropin_file($unit); +return (0, $text{'systemd_ename'}) if (!$file); +my $dir = $file; +$dir =~ s{/[^/]+$}{}; +return (0, $text{'systemd_edropinfile'}) + if (-l $dir || !-d $dir || -l $file || !-f $file); +return (1, undef) if (is_readonly_mode()); +lock_file($file); +my $ok = unlink_file($file) ? 1 : 0; +my $err = $!; +unlock_file($file); +rmdir($dir) if ($ok); +return $ok ? (1, undef) : + (0, "$file: ".($err || $text{'systemd_edropinfile'})); +} + +=head2 user_dropin_file(user, unit) + +Returns the standard drop-in override file for a user's unit. + +=cut +sub user_dropin_file +{ +my ($user, $unit) = @_; +my $root = get_user_root($user); +return if (!$root || !valid_unit_name($unit)); +return "$root/$unit.d/override.conf"; +} + +=head2 user_dropin_file_safe(user, file, [must-exist]) + +Returns 1 if a user drop-in override path is safe to read or write. + +=cut +sub user_dropin_file_safe +{ +my ($user, $file, $must_exist) = @_; +my $root = get_user_root($user); +return 0 if (!$root || !$file || $file =~ /[\0\r\n]/); +return 0 if (!user_root_safe($user)); +my $uinfo = get_user_details($user); +return 0 if (!$uinfo); +return 0 if ($file !~ /^\Q$root\E\/([^\/]+)\.d\/override\.conf$/); +return 0 if (!valid_unit_name($1)); +my $dir = "$root/$1.d"; +return 0 if (!user_unit_dir_safe($uinfo, $dir)); +return 0 if (-l $file); +return $must_exist ? -f $file : (!-e $file || -f $file); +} + +=head2 read_user_dropin_file(user, unit) + +Reads the standard user drop-in override file as the owning Unix user. + +=cut +sub read_user_dropin_file +{ +my ($user, $unit) = @_; +my $file = user_dropin_file($user, $unit); +return if (!$file || !user_dropin_file_safe($user, $file, 1)); +return eval_as_unix_user($user, sub { + return read_file_contents($file); + }); +} + +=head2 write_user_dropin_file(user, unit, data) + +Writes the standard user drop-in override file as the owning Unix user. + +=cut +sub write_user_dropin_file +{ +my ($user, $unit, $data) = @_; +my $file = user_dropin_file($user, $unit); +return (0, $text{'systemd_euserunitfile'}) + if (!$file || !user_dropin_file_safe($user, $file, 0)); +my $dir = $file; +$dir =~ s{/[^/]+$}{}; +my $uinfo = get_user_details($user); +return (0, $text{'systemd_euserunitfile'}) + if (!$uinfo || !user_unit_dir_safe($uinfo, $dir)); +return (1, undef) if (is_readonly_mode()); +my $ok = eval { + # Directory creation and writing both happen as the owner, avoiding root + # writes through user-controlled home-directory paths. + eval_as_unix_user($user, sub { + if (!-d $dir) { + make_dir($dir, oct("0755"), 0) || die "$dir: $!"; + } + die $text{'systemd_euserunitfile'} + if (-l $dir || !-d $dir || -l $file || + (-e $file && !-f $file)); + my $fh = gensym(); + open_lock_tempfile($fh, ">$file"); + print_tempfile($fh, defined($data) ? $data : ""); + close_tempfile($fh); + set_ownership_permissions(undef, undef, oct("0644"), $file); + }); + 1; + }; +my $err = $@; +$err =~ s/\s+at\s+(\/\S+)\s+line\s+(\d+)\.?// if ($err); +return $ok ? (1, undef) : (0, $err || $text{'systemd_euserunitfile'}); +} + +=head2 delete_user_dropin_file(user, unit) + +Deletes a user unit drop-in override as the owning Unix user. + +=cut +sub delete_user_dropin_file +{ +my ($user, $unit) = @_; +my $file = user_dropin_file($user, $unit); +return (0, $text{'systemd_euserunitfile'}) + if (!$file || !user_dropin_file_safe($user, $file, 1)); +my $dir = $file; +$dir =~ s{/[^/]+$}{}; +return (1, undef) if (is_readonly_mode()); +my $ok = eval { + # Re-check immediately before deletion in the user's context. + eval_as_unix_user($user, sub { + die $text{'systemd_euserunitfile'} + if (-l $dir || !-d $dir || -l $file || !-f $file); + lock_file($file); + my $deleted = unlink_file($file) ? 1 : 0; + my $err = $!; + unlock_file($file); + die "$file: $err" if (!$deleted); + rmdir($dir); + }); + 1; + }; +my $err = $@; +$err =~ s/\s+at\s+(\/\S+)\s+line\s+(\d+)\.?// if ($err); +return $ok ? (1, undef) : (0, $err || $text{'systemd_euserunitfile'}); +} + +=head2 get_system_dropin_roots() + +Returns local system unit directories that can contain administrator drop-ins. + +=cut +sub get_system_dropin_roots +{ +my @roots; +my %seen; +foreach my $root (get_system_unit_file_root_candidates()) { + next if (!local_unit_file_root($root)); + next if (!-d $root || -l $root); + my $real = eval { abs_path($root) }; + next if (!$real || $real ne $root || $seen{$real}++); + push(@roots, $root); + } +return @roots; +} + +=head2 valid_dropin_config_file_name(name) + +Returns 1 if a drop-in config file basename is safe to list. + +=cut +sub valid_dropin_config_file_name +{ +my ($name) = @_; +return 0 if (!$name || $name =~ /[\0\r\n\/]/ || $name =~ /^\./); +return $name =~ /^[a-z0-9\.\_\-\@:]+\.conf$/i ? 1 : 0; +} + +=head2 system_dropin_config_file_safe(file, [must-exist]) + +Returns 1 if a system drop-in config file is a regular file below a local +systemd unit drop-in directory. + +=cut +sub system_dropin_config_file_safe +{ +my ($file, $must_exist) = @_; +return 0 if (!$file || $file =~ /[\0\r\n]/ || -l $file); +foreach my $root (get_system_dropin_roots()) { + if ($file =~ /^\Q$root\E\/([^\/]+)\.d\/([^\/]+)$/) { + my ($unit, $conf) = ($1, $2); + return 0 if (!valid_unit_file_name($unit) || + !valid_dropin_config_file_name($conf)); + my $dir = "$root/$unit.d"; + return 0 if (-l $dir || !-d $dir); + return $must_exist ? -f $file : (!-e $file || -f $file); + } + } +return 0; +} + +=head2 system_dropin_config_file_info(file) + +Returns a descriptor for a safe system drop-in config file. + +=cut +sub system_dropin_config_file_info +{ +my ($file) = @_; +return if (!system_dropin_config_file_safe($file, 1)); +foreach my $root (get_system_dropin_roots()) { + if ($file =~ /^\Q$root\E\/([^\/]+)\.d\/([^\/]+)$/) { + my ($unit, $conf) = ($1, $2); + return { 'scope' => 'system', + 'unit' => $unit, + 'file' => $file, + 'name' => $conf, + 'standard' => $conf eq 'override.conf' && + valid_unit_name($unit) ? 1 : 0 }; + } + } +return; +} + +=head2 read_system_dropin_config_file(file) + +Reads a safe existing system drop-in config file. + +=cut +sub read_system_dropin_config_file +{ +my ($file) = @_; +return if (!system_dropin_config_file_safe($file, 1)); +lock_file($file); +my $data = read_file_contents($file); +unlock_file($file); +return $data; +} + +=head2 write_system_dropin_config_file(file, data) + +Writes a safe existing system drop-in config file. + +=cut +sub write_system_dropin_config_file +{ +my ($file, $data) = @_; +return (0, $text{'systemd_edropinfile'}) + if (!system_dropin_config_file_safe($file, 1)); +return (1, undef) if (is_readonly_mode()); +lock_file($file); +my $fh = gensym(); +open_tempfile($fh, ">$file"); +print_tempfile($fh, defined($data) ? $data : ""); +close_tempfile($fh); +unlock_file($file); +return (1, undef); +} + +=head2 user_dropin_config_file_safe(user, file, [must-exist]) + +Returns 1 if a user drop-in config file is a regular file below the selected +user's systemd unit config directory. + +=cut +sub user_dropin_config_file_safe +{ +my ($user, $file, $must_exist) = @_; +my $root = get_user_root($user); +return 0 if (!$root || !$file || $file =~ /[\0\r\n]/); +return 0 if (!user_root_safe($user)); +my $uinfo = get_user_details($user); +return 0 if (!$uinfo); +return 0 if ($file !~ /^\Q$root\E\/([^\/]+)\.d\/([^\/]+)$/); +my ($unit, $conf) = ($1, $2); +return 0 if (!valid_unit_file_name($unit) || + !valid_dropin_config_file_name($conf)); +my $dir = "$root/$unit.d"; +return 0 if (!user_unit_dir_safe($uinfo, $dir) || -l $file); +return $must_exist ? -f $file : (!-e $file || -f $file); +} + +=head2 user_dropin_config_file_info(user, file) + +Returns a descriptor for a safe user drop-in config file. + +=cut +sub user_dropin_config_file_info +{ +my ($user, $file) = @_; +return if (!user_dropin_config_file_safe($user, $file, 1)); +my $root = get_user_root($user); +return if (!$root || $file !~ /^\Q$root\E\/([^\/]+)\.d\/([^\/]+)$/); +my ($unit, $conf) = ($1, $2); +return { 'scope' => 'user', + 'user' => $user, + 'unit' => $unit, + 'file' => $file, + 'name' => $conf, + 'standard' => $conf eq 'override.conf' && + valid_unit_name($unit) ? 1 : 0 }; +} + +=head2 read_user_dropin_config_file(user, file) + +Reads a safe existing user drop-in config file as the owning Unix user. + +=cut +sub read_user_dropin_config_file +{ +my ($user, $file) = @_; +return if (!user_dropin_config_file_safe($user, $file, 1)); +return eval_as_unix_user($user, sub { + return read_file_contents($file); + }); +} + +=head2 write_user_dropin_config_file(user, file, data) + +Writes a safe existing user drop-in config file as the owning Unix user. + +=cut +sub write_user_dropin_config_file +{ +my ($user, $file, $data) = @_; +return (0, $text{'systemd_edropinfile'}) + if (!user_dropin_config_file_safe($user, $file, 1)); +return (1, undef) if (is_readonly_mode()); +my $ok = eval { + eval_as_unix_user($user, sub { + die $text{'systemd_edropinfile'} + if (!user_dropin_config_file_safe($user, $file, 1)); + my $fh = gensym(); + open_lock_tempfile($fh, ">$file"); + print_tempfile($fh, defined($data) ? $data : ""); + close_tempfile($fh); + set_ownership_permissions(undef, undef, oct("0644"), $file); + }); + 1; + }; +my $err = $@; +$err =~ s/\s+at\s+(\/\S+)\s+line\s+(\d+)\.?// if ($err); +return $ok ? (1, undef) : (0, $err || $text{'systemd_edropinfile'}); +} + +=head2 list_system_dropin_override_files() + +Returns safe local system drop-in config files as descriptors. + +=cut +sub list_system_dropin_override_files +{ +my %seen; +foreach my $root (get_system_dropin_roots()) { + opendir(my $root_dh, $root) || next; + foreach my $entry (readdir($root_dh)) { + next if ($entry !~ /^(.+)\.d$/); + my $unit = $1; + next if (!valid_unit_file_name($unit)); + my $dir = "$root/$entry"; + next if (-l $dir || !-d $dir); + opendir(my $dropin_dh, $dir) || next; + foreach my $conf (readdir($dropin_dh)) { + next if (!valid_dropin_config_file_name($conf)); + my $file = "$dir/$conf"; + next if (!system_dropin_config_file_safe($file, 1)); + $seen{$file} = { + 'scope' => 'system', + 'unit' => $unit, + 'file' => $file, + 'name' => $conf, + 'standard' => $conf eq 'override.conf' && + valid_unit_name($unit) ? 1 : 0, + }; + } + closedir($dropin_dh); + } + closedir($root_dh); + } +my @files = sort { $a->{'unit'} cmp $b->{'unit'} || + $a->{'file'} cmp $b->{'file'} } values(%seen); +return @files; +} + +=head2 list_user_dropin_override_files(user) + +Returns safe drop-in config files for one user's systemd manager. + +=cut +sub list_user_dropin_override_files +{ +my ($user) = @_; +my $root = get_user_root($user); +return ( ) if (!$root || !user_root_safe($user) || !-d $root || -l $root); +my @candidates = eval_as_unix_user($user, sub { + my @rv; + opendir(my $root_dh, $root) || return ( ); + foreach my $entry (readdir($root_dh)) { + next if ($entry !~ /^(.+)\.d$/); + my $unit = $1; + next if (!valid_unit_file_name($unit)); + my $dir = "$root/$entry"; + next if (-l $dir || !-d $dir); + opendir(my $dropin_dh, $dir) || next; + foreach my $conf (readdir($dropin_dh)) { + next if (!valid_dropin_config_file_name($conf)); + push(@rv, [ $unit, $conf, "$dir/$conf" ]); + } + closedir($dropin_dh); + } + closedir($root_dh); + return @rv; + }); +my %seen; +foreach my $candidate (@candidates) { + next if (!$candidate || ref($candidate) ne 'ARRAY'); + my ($unit, $conf, $file) = @$candidate; + next if (!user_dropin_config_file_safe($user, $file, 1)); + $seen{$file} = { + 'scope' => 'user', + 'user' => $user, + 'unit' => $unit, + 'file' => $file, + 'name' => $conf, + 'standard' => $conf eq 'override.conf' && + valid_unit_name($unit) ? 1 : 0, + }; + } +my @files = sort { $a->{'unit'} cmp $b->{'unit'} || + $a->{'file'} cmp $b->{'file'} } values(%seen); +return @files; +} + +=head2 list_all_user_dropin_override_files() + +Returns safe local user-unit drop-in config files from users' home +directories. + +=cut +sub list_all_user_dropin_override_files +{ +return ( ) if (!tab_visible('user')); +my @rv; +setpwent(); +while(my @uinfo = getpwent()) { + my ($user, $home) = ($uinfo[0], $uinfo[7]); + next if (!$user || $home !~ /^\//); + push(@rv, list_user_dropin_override_files($user)); + } +endpwent(); +my @files = sort { ($a->{'user'} || "") cmp ($b->{'user'} || "") || + $a->{'unit'} cmp $b->{'unit'} || + $a->{'file'} cmp $b->{'file'} } @rv; +return @files; +} + +=head2 dropin_template(override-file, base-file, base-data) + +Returns the initial comment-only contents for a new drop-in override. + +=cut +sub dropin_template +{ +my ($override_file, $base_file, $base_data) = @_; +$override_file = "" if (!defined($override_file)); +$base_file = "" if (!defined($base_file)); +$base_data = "" if (!defined($base_data)); +my $data = "### Editing $override_file\n"; +$data .= "### Anything between here and the comment below will become ". + "the new contents of the file\n\n\n\n"; +$data .= "### Lines below this comment will be discarded\n\n"; +$data .= "### $base_file\n"; +foreach my $line (split(/\n/, $base_data, -1)) { + $data .= "# $line\n"; + } +return $data; +} + +=head2 dropin_effective_data(data) + +Returns only the editable portion of a C-style drop-in file. + +=cut +sub dropin_effective_data +{ +my ($data) = @_; +$data = "" if (!defined($data)); +$data =~ s/^### Lines below this comment will be discarded\s*\n.*\z//ms; +return $data; +} + +=head2 delete_user_unit_file(user, file) + +Deletes a user unit file as the owning Unix user after path validation, so a +symlinked path component cannot trick root into removing files outside the +user's systemd unit config directory. + +=cut +sub delete_user_unit_file +{ +my ($user, $file) = @_; +return 0 if (!user_unit_file_safe($user, $file, 0)); +return 1 if (is_readonly_mode()); +return eval_as_unix_user($user, sub { + # Re-check in the user's context immediately before unlinking. + return 1 if (!-e $file && !-l $file); + return 0 if (-l $file || !-f $file); + return unlink_file($file) ? 1 : 0; + }); +} + +=head2 make_user_root(user) + +Creates the base directory for a user's systemd unit config files. + +=cut +sub make_user_root +{ +my ($user) = @_; +my $uinfo = get_user_details($user); +return if (!$uinfo); +my @dirs = ( $uinfo->{'home'}."/.config", + $uinfo->{'home'}."/.config/systemd", + $uinfo->{'home'}."/.config/systemd/user" ); +foreach my $dir (@dirs) { + return if (-l $dir || (-e $dir && !-d $dir)); + } +return $dirs[-1] if (is_readonly_mode() && user_root_safe($user)); +my $ok = eval { + # Create the directory tree as the owning user, then validate it again in + # root context before returning the path. + eval_as_unix_user($uinfo->{'user'}, sub { + foreach my $dir (@dirs) { + return 0 if (-l $dir || (-e $dir && !-d $dir)); + if (!-d $dir) { + make_dir($dir, oct("0755"), 0) || return 0; + } + } + return 1; + }); + }; +return if (!$ok || !user_root_safe($user)); +return $dirs[-1]; +} + +=head2 user_manager_command(user, command, ...) + +Returns a command line that runs a command inside the user's systemd context. + +=cut +sub user_manager_command +{ +my ($user, @cmd) = @_; +my $uinfo = get_user_details($user); +return if (!$uinfo); +my $runtime = "/run/user/".$uinfo->{'uid'}; + +# systemctl --user needs the user's home, runtime directory and bus address +# even though the CGI is running from Webmin's root-owned environment. +my $env = "HOME=".quotemeta($uinfo->{'home'})." ". + "XDG_RUNTIME_DIR=".quotemeta($runtime)." ". + "DBUS_SESSION_BUS_ADDRESS=".quotemeta("unix:path=".$runtime."/bus"); +return command_as_user($uinfo->{'user'}, 0, $env." ".join(" ", @cmd)); +} + +=head2 user_systemctl_command(user, arg, ...) + +Returns a quoted systemctl --user command line for some user. + +=cut +sub user_systemctl_command +{ +my ($user, @args) = @_; +my $systemctl = has_command("systemctl") || "systemctl"; + +# Quote each argument before wrapping the command with command_as_user. +return user_manager_command($user, quotemeta($systemctl), "--user", + map { quotemeta($_) } @args); +} + +=head2 run_user_systemctl(user, arg, ...) + +Runs systemctl --user for some user, returning an OK flag and output. + +=cut +sub run_user_systemctl +{ +my ($user, @args) = @_; +my $cmd = user_systemctl_command($user, @args); +return (0, $text{'systemd_euser'}) if (!$cmd); +my $out = backquote_logged($cmd." 2>&1 {'user'}); +my $out = backquote_logged($cmd." 2>&1 {'user'}); +my $loginctl = has_command("loginctl"); +return 0 if (!$loginctl); +my $out = backquote_command(quotemeta($loginctl)." show-user ". + quotemeta($uinfo->{'user'}). + " -p Linger 2>/dev/null"); +return $out =~ /^Linger=yes/m ? 1 : 0; +} + +=head2 start_user_manager(user) + +Starts a user's systemd manager through the system manager. + +=cut +sub start_user_manager +{ +my ($user) = @_; +my $uinfo = get_user_details($user); +return (0, $text{'systemd_euser'}) if (!$uinfo); +my $systemctl = has_command("systemctl") || "systemctl"; + +# User managers are addressed by UID as user@UID.service. +my $unit = "user\@".$uinfo->{'uid'}.".service"; +my $out = backquote_logged(quotemeta($systemctl)." start ". + quotemeta($unit)." 2>&1 /dev/null"); +foreach my $l (split(/\r?\n/, $out)) { + $l =~ s/^[^a-z0-9\-\_\.]+//i; + my ($unit, $loaded) = split(/\s+/, $l, 3); + push(@units, $unit) + if ($unit && $unit ne "UNIT" && $loaded eq "loaded"); + } + +# Also add units from list-unit-files that may not be loaded. +$out = backquote_command( + user_systemctl_command($user, "list-unit-files", + split(/\s+/, $list_types), + "--no-legend"). + " 2>/dev/null"); +foreach my $l (split(/\r?\n/, $out)) { + if ($l =~ /^(\S+)\s+/) { + push(@units, $1); + } + } + +@units = grep { !/\@$/ && !/\@\.($units_piped)$/ } unique(@units); + +# Dump state in batches, keeping command lines short and parsing the property +# format into one hash per unit. +my @show_units = @units; +my %info; +while(@show_units) { + my @args; + while(@args < 100 && @show_units) { + push(@args, shift(@show_units)); + } + my $cmd = user_systemctl_command( + $user, "show", + "--property=Id,Description,UnitFileState,ActiveState,SubState,ExecStart,ExecStop,ExecReload,ExecMainPID,FragmentPath,DropInPaths", + @args); + my $show = backquote_command($cmd." 2>/dev/null"); + my @lines = split(/\r?\n/, $show); + my $curr; + my @shown; + + # systemctl show separates units with blank lines. + if (@lines) { + $curr = { }; + push(@shown, $curr); + } + foreach my $l (@lines) { + if ($l eq "") { + $curr = { }; + push(@shown, $curr); + } + else { + my ($n, $v) = split(/=/, $l, 2); + $curr->{$n} = $v; + } + } + foreach my $u (@shown) { + $info{$u->{'Id'}} = $u if ($u->{'Id'}); + } + } + +my @rv; +my %done; +foreach my $name (sort keys %info) { + my $i = $info{$name}; + my $file = $i->{'FragmentPath'} || $local_files{$name}; + + # Only expose local user-owned files. Vendor user units are not editable + # here because deletion/editing would not affect their source. + next if ($root && (!$file || $file !~ /^\Q$root\E\//)); + next if (!user_unit_file_safe($user, $file, 1)); + my $desc = user_file_description($user, $file, $name); + $desc = $i->{'Description'} if (!defined($desc)); + next if (defined($desc) && $desc =~ /^LSB:\s/); + push(@rv, { 'name' => $name, + 'desc' => defined($desc) ? $desc : "", + 'unitstate' => $i->{'UnitFileState'}, + 'runtime' => $i->{'ActiveState'}, + 'substate' => $i->{'SubState'}, + 'boot' => $i->{'UnitFileState'} =~ /^enabled/ ? 1 : + $i->{'UnitFileState'} eq 'static' ? 2 : + $i->{'UnitFileState'} eq 'masked' ? -1 : 0, + 'status' => $i->{'ActiveState'} eq 'active' ? 1 : 0, + 'start' => $i->{'ExecStart'}, + 'stop' => $i->{'ExecStop'}, + 'reload' => $i->{'ExecReload'}, + 'pid' => $i->{'ExecMainPID'}, + 'file' => $file, + 'user' => $uinfo->{'user'}, + }); + $done{$name}++; + } + +foreach my $name (sort keys %local_files) { + next if ($done{$name}); + + # Include local files even when the user manager is offline and cannot + # report them through systemctl show. + my $enabled = user_file_enabled($user, $name); + push(@rv, { 'name' => $name, + 'desc' => user_file_description( + $user, $local_files{$name}, $name) || "", + 'unitstate' => $enabled ? 'enabled' : 'disabled', + 'runtime' => undef, + 'boot' => $enabled, + 'status' => undef, + 'file' => $local_files{$name}, + 'user' => $uinfo->{'user'}, + }); + } + + my @sorted = sort { $a->{'name'} cmp $b->{'name'} } @rv; + return @sorted; + } + +=head2 list_all_user_units() + +Returns all locally editable systemd user units from users' home directories. + +=cut +sub list_all_user_units +{ +return ( ) if (!tab_visible('user')); +my @rv; +setpwent(); +while(my @uinfo = getpwent()) { + my ($user, $home) = ($uinfo[0], $uinfo[7]); + + # Only users with absolute home directories can have user unit roots. + next if (!$user || $home !~ /^\//); + my $root = $home."/.config/systemd/user"; + next if (!user_root_safe($user) || !-d $root); + push(@rv, list_user_units($user)); + } +endpwent(); +my @sorted = sort { $a->{'user'} cmp $b->{'user'} || + $a->{'name'} cmp $b->{'name'} } @rv; +return @sorted; +} + +=head2 get_system_unit_file_roots() + +Returns existing system and vendor directories that can contain unit files. + +=cut +sub get_system_unit_file_roots +{ +my @roots; +my %seen; +foreach my $root (get_system_unit_file_root_candidates()) { + next if (!$config{'manual_vendor_units'} && !local_unit_file_root($root)); + next if (!-d $root || -l $root); + my $real = eval { abs_path($root) }; + next if (!$real || $real ne $root || $seen{$real}++); + push(@roots, $root); + } +return @roots; +} + +=head2 local_unit_file_root(root) + +Returns true if a system unit root is the local administrator directory. + +=cut +sub local_unit_file_root +{ +my ($root) = @_; +return $root eq "/etc/systemd/system"; +} + +=head2 get_system_unit_file_root_candidates() + +Returns possible systemd unit directories before existence and symlink checks. + +=cut +sub get_system_unit_file_root_candidates +{ +return ("/etc/systemd/system", + "/usr/lib/systemd/system", + "/lib/systemd/system"); +} + +=head2 manual_system_unit_file_safe(file) + +Returns 1 if a system unit file is a regular direct child of a known systemd +unit directory and has a recognized unit suffix. + +=cut +sub manual_system_unit_file_safe +{ +my ($file) = @_; +return 0 if (!$file || $file =~ /[\0\r\n]/ || -l $file || !-f $file); +foreach my $root (get_system_unit_file_roots()) { + if ($file =~ /^\Q$root\E\/([^\/]+)$/ && + valid_unit_file_name($1)) { + return 1; + } + } +return 0; +} + +=head2 list_manual_unit_files() + +Returns system and local user unit files that the raw editor may open. + +=cut +sub list_manual_unit_files +{ +my %files; + +# Scan local and vendor unit directories directly, including unit types hidden +# from the main management tabs. +foreach my $root (get_system_unit_file_roots()) { + opendir(my $units_dh, $root) || next; + foreach my $name (readdir($units_dh)) { + my $file = "$root/$name"; + next if (!manual_system_unit_file_safe($file)); + $files{$file} = { 'file' => $file, + 'name' => $name, + 'scope' => 'system' }; + } + closedir($units_dh); + } + +# Add any fragment paths reported by systemctl in case a unit lives in a +# distro-specific root not covered above. +my @system_units = list_units(); +foreach my $u (@system_units) { + my $file = $u->{'file'}; + next if (!manual_system_unit_file_safe($file)); + $files{$file} ||= { 'file' => $file, + 'name' => $u->{'name'}, + 'scope' => 'system' }; + } + +# System drop-ins are editable from the raw editor when their base unit is a +# known file-backed unit. This includes package or module snippets such as +# 00-virtualmin.conf, not only the module's default override.conf. +my %system_units = map { $_->{'name'}, $_ } @system_units; +foreach my $dropin (list_system_dropin_override_files()) { + my $u = $system_units{$dropin->{'unit'}}; + next if (!$u || !manual_system_unit_file_safe($u->{'file'})); + $files{$dropin->{'file'}} = { %$dropin, + 'kind' => 'dropin', + 'name' => $dropin->{'unit'}, + 'dropin' => $dropin->{'name'}, + 'unitfile' => $u->{'file'}, + 'unitstate' => $u->{'unitstate'} }; + } + +# User unit files remain constrained to local home-owned unit roots. +my @user_units = list_all_user_units(); +foreach my $u (@user_units) { + my $file = $u->{'file'}; + next if (!$u->{'user'} || + !user_unit_file_safe($u->{'user'}, $file, 1)); + $files{$file} = { 'file' => $file, + 'name' => $u->{'name'}, + 'scope' => 'user', + 'user' => $u->{'user'} }; + } + +# User drop-ins are constrained to discovered user units owned by the same +# Unix account and are written as that user. +my %user_units = map { $_->{'user'}."\t".$_->{'name'}, $_ } @user_units; +foreach my $dropin (list_all_user_dropin_override_files()) { + my $u = $user_units{$dropin->{'user'}."\t".$dropin->{'unit'}}; + next if (!$u || + !user_unit_file_safe($dropin->{'user'}, $u->{'file'}, 1)); + $files{$dropin->{'file'}} = { %$dropin, + 'kind' => 'dropin', + 'name' => $dropin->{'unit'}, + 'dropin' => $dropin->{'name'}, + 'unitfile' => $u->{'file'}, + 'unitstate' => $u->{'unitstate'} }; + } + + my @files = sort { ($a->{'scope'} || "") cmp ($b->{'scope'} || "") || + ($a->{'user'} || "") cmp ($b->{'user'} || "") || + $a->{'file'} cmp $b->{'file'} } values(%files); + return @files; + } + +=head2 manual_unit_file(file) + +Returns the manual-edit descriptor for an allowed systemd unit file. + +=cut +sub manual_unit_file +{ +my ($file) = @_; +return if (!$file); +foreach my $info (list_manual_unit_files()) { + return $info if ($info->{'file'} eq $file); + } +return; +} + +=head2 read_manual_unit_file(info) + +Reads a system or user unit file selected through C. + +=cut +sub read_manual_unit_file +{ +my ($info) = @_; +return if (!$info || !$info->{'file'}); +if ($info->{'kind'} && $info->{'kind'} eq 'dropin') { + return $info->{'scope'} eq 'user' ? + read_user_dropin_config_file($info->{'user'}, + $info->{'file'}) : + read_system_dropin_config_file($info->{'file'}); + } +if ($info->{'scope'} eq 'user') { + return read_user_unit_file($info->{'user'}, $info->{'file'}); + } +return if (!manual_system_unit_file_safe($info->{'file'})); +lock_file($info->{'file'}); +my $data = read_file_contents($info->{'file'}); +unlock_file($info->{'file'}); +return $data; +} + +=head2 write_manual_unit_file(info, data) + +Writes a system or user unit file selected through C. + +=cut +sub write_manual_unit_file +{ +my ($info, $data) = @_; +return (0, $text{'manual_efile'}) + if (!$info || !$info->{'file'}); +$data = "" if (!defined($data)); +$data =~ s/\0//g; +$data =~ s/\r//g; +if ($info->{'kind'} && $info->{'kind'} eq 'dropin') { + return (0, $text{'manual_efile'}) + if (!$info->{'unitfile'}); + my $user_scope = $info->{'scope'} eq 'user' ? 1 : 0; + my $unit_data; + if ($user_scope) { + $unit_data = read_user_unit_file($info->{'user'}, + $info->{'unitfile'}); + } + else { + return (0, $text{'manual_efile'}) + if (!manual_system_unit_file_safe($info->{'unitfile'})); + $unit_data = read_file_contents($info->{'unitfile'}); + } + my ($vok, $vout) = verify_dropin_data( + $info->{'unitfile'}, $unit_data, $data, $user_scope, + $info->{'unitstate'}, $info->{'user'}); + return (0, $vout) if (!$vok); + return $user_scope ? + write_user_dropin_config_file($info->{'user'}, + $info->{'file'}, $data) : + write_system_dropin_config_file($info->{'file'}, $data); + } +my ($vok, $vout) = verify_unit_data($info->{'file'}, $data, + $info->{'scope'} eq 'user', + $info->{'user'}); +return (0, $vout) if (!$vok); +if ($info->{'scope'} eq 'user') { + return write_user_unit_file($info->{'user'}, $info->{'file'}, $data); + } +return (0, $text{'manual_efile'}) + if (!manual_system_unit_file_safe($info->{'file'})); +return (1, undef) if (is_readonly_mode()); +lock_file($info->{'file'}); +my $fh = gensym(); +open_tempfile($fh, ">".$info->{'file'}); +print_tempfile($fh, $data); +close_tempfile($fh); +unlock_file($info->{'file'}); +return (1, undef); +} + +=head2 mark_units_changed() + +Updates the flag file indicating that manual unit-file edits need reload. + +=cut +sub mark_units_changed +{ +open_lock_tempfile(my $fh, ">$unit_config_change_flag", 0, 1); +close_tempfile($fh); +} + +=head2 mark_daemon_reloaded() + +Updates the flag file indicating that systemd has re-read unit files. + +=cut +sub mark_daemon_reloaded +{ +open_lock_tempfile(my $fh, ">$daemon_reload_time_flag", 0, 1); +close_tempfile($fh); +} + +=head2 user_daemon_reload_flag_file(user, type) + +Returns the per-user reload flag path for C or C. + +=cut +sub user_daemon_reload_flag_file +{ +my ($user, $type) = @_; +return if (!$user || $type !~ /^(changed|reloaded)$/); +my $uinfo = get_user_details($user); +return if (!$uinfo); +return $module_var_directory."/user-daemon-reload-". + $uinfo->{'uid'}."-".$type; +} + +=head2 mark_user_units_changed(user) + +Updates the flag file indicating that a user's unit files need reload. + +=cut +sub mark_user_units_changed +{ +my ($user) = @_; +my $flag = user_daemon_reload_flag_file($user, 'changed'); +return if (!$flag); +open_lock_tempfile(my $fh, ">$flag", 0, 1); +close_tempfile($fh); +} + +=head2 mark_user_daemon_reloaded(user) + +Updates the flag file indicating that a user's manager has re-read unit files. + +=cut +sub mark_user_daemon_reloaded +{ +my ($user) = @_; +my $flag = user_daemon_reload_flag_file($user, 'reloaded'); +return if (!$flag); +open_lock_tempfile(my $fh, ">$flag", 0, 1); +close_tempfile($fh); +} + +=head2 needs_daemon_reload() + +Returns 1 if unit files were manually edited after the last daemon reload. + +=cut +sub needs_daemon_reload +{ +my @changed = stat($unit_config_change_flag); +my @reloaded = stat($daemon_reload_time_flag); +return 0 if (!@changed); +return 1 if (!@reloaded); +return $changed[9] > $reloaded[9] ? 1 : 0; +} + +=head2 needs_user_daemon_reload(user) + +Returns 1 if a user's unit files were edited after that user's last reload. + +=cut +sub needs_user_daemon_reload +{ +my ($user) = @_; +my $changed_flag = user_daemon_reload_flag_file($user, 'changed'); +return 0 if (!$changed_flag); +my @changed = stat($changed_flag); +return 0 if (!@changed); +my $reloaded_flag = user_daemon_reload_flag_file($user, 'reloaded'); +my @reloaded = $reloaded_flag ? stat($reloaded_flag) : ( ); +return 1 if (!@reloaded); +return $changed[9] > $reloaded[9] ? 1 : 0; +} + +=head2 action_reload_user([user]) + +Returns the user whose manager reload action should be shown, if any. + +=cut +sub action_reload_user +{ +my ($user) = @_; +$user ||= defined($in{'unituser'}) ? clean_unit_value($in{'unituser'}) : ""; +$user ||= defined($in{'user'}) ? clean_unit_value($in{'user'}) : ""; +$user ||= systemd_acl_default_user(\%access) || ""; +my $uinfo = $user ? get_user_details($user) : undef; +return $uinfo ? $uinfo->{'user'} : undef; +} + +=head2 action_links() + +Returns HTML for right-side header actions on the systemd index page. + +=cut +sub action_links +{ +my ($user) = @_; +my @links; +push(@links, ui_link("restart.cgi", + ui_tag('b', html_escape($text{'index_reload'})))) + if (needs_daemon_reload() && systemd_can_reload(\%access)); +my $reload_user = action_reload_user($user); +push(@links, ui_link("restart_user.cgi?user=".urlize($reload_user), + ui_tag('b', html_escape($text{'index_reload_user'})))) + if ($reload_user && + needs_user_daemon_reload($reload_user) && + systemd_can_reload_user(\%access, $reload_user)); +return join("   ", @links); +} + +=head2 start_user_unit(user, name) + +Starts a systemd user unit and returns an OK flag and output. + +=cut +sub start_user_unit +{ +my ($user, $name) = @_; +return (0, $text{'systemd_ename'}) if (!valid_unit_name($name)); +my ($ok, $out) = run_user_systemctl($user, "start", $name); +if (!$ok && $out =~ /journalctl/) { + my ($lok, $lout) = logs_user_unit($user, $name); + $out .= $lout if ($lout); + } +return ($ok, $out); +} + +=head2 stop_user_unit(user, name) + +Stops a systemd user unit. + +=cut +sub stop_user_unit +{ +my ($user, $name) = @_; +return (0, $text{'systemd_ename'}) if (!valid_unit_name($name)); +return run_user_systemctl($user, "stop", $name); +} + +=head2 restart_user_unit(user, name) + +Restarts a systemd user unit. + +=cut +sub restart_user_unit +{ +my ($user, $name) = @_; +return (0, $text{'systemd_ename'}) if (!valid_unit_name($name)); +return run_user_systemctl($user, "restart", $name); +} + +=head2 status_user_unit(user, name) + +Gets full status output for a systemd user unit. + +=cut +sub status_user_unit +{ +my ($user, $name) = @_; +return (0, $text{'systemd_ename'}) if (!valid_unit_name($name)); +return run_user_systemctl($user, "--full", "--no-pager", + "status", $name); +} + +=head2 properties_user_unit(user, name) + +Gets systemd property output for a user unit. + +=cut +sub properties_user_unit +{ +my ($user, $name) = @_; +return (0, $text{'systemd_ename'}) if (!valid_unit_name($name)); +return run_user_systemctl($user, "--full", "--no-pager", + "show", $name); +} + +=head2 dependencies_user_unit(user, name) + +Gets dependency tree output for a systemd user unit. + +=cut +sub dependencies_user_unit +{ +my ($user, $name) = @_; +return (0, $text{'systemd_ename'}) if (!valid_unit_name($name)); +return run_user_systemctl($user, "--full", "--no-pager", + "list-dependencies", $name); +} + +=head2 logs_user_unit(user, name) + +Gets recent journal logs for a systemd user unit. + +=cut +sub logs_user_unit +{ +my ($user, $name) = @_; +return (0, $text{'systemd_ename'}) if (!valid_unit_name($name)); +my $uinfo = get_user_details($user); +return (0, $text{'systemd_euser'}) if (!$uinfo); +my $journalctl = has_command("journalctl"); +return (0, $text{'systemd_ejournal'}) if (!$journalctl); +my $boot_arg = $config{'logs_current_boot'} ? " --boot" : ""; +my $out = backquote_logged( + quotemeta($journalctl)." --no-pager ". + "_UID=".int($uinfo->{'uid'})." ". + "_SYSTEMD_USER_UNIT=".quotemeta($name). + " --lines ".int($config{'logs_lines'}).$boot_arg. + " 2>&1 {'file'}); +return 0 if (defined($unit->{'name'}) && $unit->{'name'} =~ /\.scope$/i); +my $state = defined($unit->{'unitstate'}) ? lc($unit->{'unitstate'}) : ""; +return 0 if ($state eq 'transient' || $state eq 'generated'); +return 0 if ($unit->{'file'} =~ m{/systemd/(transient|generator)/}); +return 1; +} + +=head2 unit_visible_on_index(&unit) + +Returns 1 if a unit should be included on index tabs, honoring the option to +hide generated and transient runtime units. + +=cut +sub unit_visible_on_index +{ +my ($unit) = @_; +return 1 if ($config{'show_runtime_units'}); +return 0 if (!$unit); +my $state = defined($unit->{'unitstate'}) ? lc($unit->{'unitstate'}) : ""; +return 0 if ($state eq 'transient' || $state eq 'generated'); +return 0 if (defined($unit->{'file'}) && + $unit->{'file'} =~ m{/systemd/(transient|generator)/}); +return 1; +} + +=head2 tab_visible(tab-id) + +Returns true if the given index tab should be shown. + +=cut +sub tab_visible +{ +my ($tab) = @_; +return indexof($tab, get_visible_tabs()) >= 0; +} + +=head2 get_unit_type_from_name(name) + +Returns the systemd unit type suffix from a full unit name, such as service or +timer, if it is a known unit type. + +=cut +sub get_unit_type_from_name +{ +my ($name) = @_; +return if (!defined($name)); +my $units_piped = join('|', map { quotemeta } get_unit_types()); +return lc($1) if ($name =~ /\.($units_piped)$/i); +return; +} + +=head2 index_url([unit-name], [user-scope], [user]) + +Returns the module index URL with the correct systemd tab selected when +the unit type or user scope is known. + +=cut +sub index_url +{ +my ($name, $user_scope, $user) = @_; +my @args; +if ($user_scope) { + push(@args, "mode=user"); + if ($user) { + push(@args, "scope=user"); + push(@args, "unituser=".urlize($user)); + } + } +else { + my $type = get_unit_type_from_name($name); + my %group_mode = ( 'mount' => 'storage', + 'automount' => 'storage', + 'swap' => 'storage', + 'slice' => 'resources', + 'scope' => 'resources', + 'device' => 'device' ); + my %list_types = map { $_, 1 } get_list_unit_types(); + if ($type && $list_types{$type}) { + push(@args, "mode=".urlize($group_mode{$type} || $type)); + } + } +return "index.cgi".(@args ? "?".join("&", @args) : ""); +} + +=head2 get_unit_section(type) + +Returns the type-specific section name for a systemd unit type. + +=cut +sub get_unit_section +{ +my ($type) = @_; +my %sections = ( 'service' => 'Service', + 'timer' => 'Timer', + 'socket' => 'Socket', + 'path' => 'Path', + 'target' => 'Target', + 'mount' => 'Mount', + 'automount' => 'Automount', + 'swap' => 'Swap', + 'slice' => 'Slice' ); +return $sections{$type}; +} + +=head2 get_default_install_target(type, [user-scope]) + +Returns the default WantedBy target for a new systemd unit. + +=cut +sub get_default_install_target +{ +my ($type, $user_scope) = @_; +my %targets = ( 'service' => $user_scope ? 'default.target' : 'multi-user.target', + 'timer' => 'timers.target', + 'socket' => 'sockets.target', + 'path' => 'paths.target', + 'target' => $user_scope ? 'default.target' : 'multi-user.target', + 'mount' => $user_scope ? 'default.target' : 'local-fs.target', + 'automount' => $user_scope ? 'default.target' : 'local-fs.target', + 'swap' => $user_scope ? 'default.target' : 'swap.target', + 'slice' => 'slices.target' ); +return $targets{$type}; +} + +=head2 is_unit(name) + +Returns 1 if some unit is managed by systemd. + +=cut +sub is_unit +{ +my ($name) = @_; +return 0 if (!valid_unit_name($name)); +foreach my $s (list_units(1)) { + if ($s->{'name'} eq $name) { + return 1; + } + } +return 0; +} + +=head2 get_unit_root([name], [packaged]) + +Returns the base directory for systemd unit config files. + +=cut +sub get_unit_root +{ +my ($name, $packaged) = @_; +# Common system and vendor unit directories. +my $systemd_local_conf = "/etc/systemd/system"; +my $systemd_unit_dir1 = "/usr/lib/systemd/system"; +my $systemd_unit_dir2 = "/lib/systemd/system"; +if ($name) { + foreach my $p ($systemd_local_conf, $systemd_unit_dir1, + $systemd_unit_dir2) { + foreach my $t (get_unit_types()) { + return $p if (-r "$p/$name.$t"); + } + return $p if (-r "$p/$name"); + } + } +# Always use /etc/systemd/system for locally created units. +return $systemd_local_conf if (!$packaged && -d $systemd_local_conf); + +# Debian prefers /lib/systemd/system for packaged units. +if ($gconfig{'os_type'} eq 'debian-linux' && + -d $systemd_unit_dir2) { + return $systemd_unit_dir2; + } +# RHEL and many other systems use /usr/lib/systemd/system. +if (-d $systemd_unit_dir1) { + return $systemd_unit_dir1; + } +# Fallback path for other systems. +return $systemd_unit_dir2; +} + + +=head2 get_unit_pid([name]) + +Returns the PID of a running systemd unit, or 0 if stopped or missing. + +=cut +sub get_unit_pid +{ +my ($unit) = @_; +return 0 if (!valid_unit_name($unit)); +my $pid = + backquote_command("systemctl show --property MainPID @{[quotemeta($unit)]}"); +$pid =~ s/MainPID=(\d+)/$1/; +$pid = int($pid); +return $pid; +} + +=head2 reload_manager() + +Tells the systemd system manager to re-read its unit files. + +=cut +sub reload_manager +{ +if (has_command("systemctl")) { + system_logged("systemctl daemon-reload >/dev/null 2>&1"); + } +else { + my @pids = find_byname("systemd"); + if (@pids) { + kill_logged('HUP', @pids); + } + } +} + +=head2 is_active(unit-name) + +Check if a systemd unit is active. + +=cut +sub is_active +{ +my $unit = shift; +return wantarray ? (1, $text{'systemd_ename'}) : 0 + if (!valid_unit_name($unit)); +my $out = backquote_logged( + "systemctl is-active ".quotemeta($unit)." 2>&1 ) { + $line =~ s/\r|\n//g; + next if $line =~ /^$/; + if ($line =~ /^#\s+(\/.*)$/) { + # File name line, e.g. "# /usr/lib/systemd/system/ssh.socket". + $current_file = $1; + push @config, { file => $current_file, sections => {} }; + } + elsif ($line =~ /^\[(.+?)\]$/) { + # Section header, e.g. "[Unit]". + $current_section = $1; + $config[-1]{'sections'}{$current_section} ||= {}; + } + elsif ($line =~ /^([^=]+)=(.*)$/ && $current_section) { + # Key-value pair, e.g. "ListenStream=0.0.0.0:22". + my ($key, $value) = ($1, $2); + push @{ $config[-1]{'sections'}{$current_section}{$key} }, $value; + } + } +close($cat); + +# Keep only matching keys when a filter was requested. +if ($filter) { + my $regex = qr/$filter/; + if ($filter =~ m{^/(.+)/([igmsx]*)$}) { + # Accept JavaScript-style /pattern/flags filters from callers. + my ($pattern, $flags) = ($1, $2); + $flags =~ s/g//g; + my $prefix = $flags ? "(?$flags)" : ""; + $regex = qr/$prefix$pattern/; + } + foreach my $conf (@config) { + my $filtered_sections = {}; + foreach my $section_name (keys %{$conf->{'sections'}}) { + my $section = $conf->{'sections'}{$section_name}; + my %matching_params; + foreach my $param (keys %$section) { + $matching_params{$param} = $section->{$param} + if ($param =~ $regex); + } + $filtered_sections->{$section_name} = + \%matching_params if %matching_params; + } + $conf->{'sections'} = $filtered_sections; + } + } +return \@config; +} + +=head2 edit_unit(unit-name, new-config, [override_filename], [override_dir]) + +Edits a systemd drop-in override while preserving unrelated settings. + +Example: + + edit_unit('ssh.socket', { + 'Socket' => { + 'ListenStream' => [ + '', + '0.0.0.0:2213', + '[::]:2213' + ], + }, + 'Install' => {}, + }); + +Note that option values must always be an array reference, even if there is only +one value; if undef is passed, the key will be removed from the section; if a +section set to empty hash, the section will be removed from the unit file. + +=cut +sub edit_unit +{ +my ($unit, $new_config, $override_filename, $override_dir) = @_; +valid_unit_name($unit) || error($text{'systemd_ename'}); +$override_dir ||= "/etc/systemd/system/$unit.d"; +$override_filename ||= "override.conf"; +my $override_file = "$override_dir/$override_filename"; + +# Create the drop-in directory before reading or writing the override. +if (!-d($override_dir)) { + make_dir($override_dir, oct("0755"), 0) || + error("Failed to create directory '$override_dir': $!"); + } + +# Read the existing override so unrelated keys can be preserved. +my $existing_config = {}; +if (-f($override_file)) { + my $content = read_file_contents($override_file); + my $current_section; + foreach my $line (split(/\r?\n/, $content)) { + next if ($line =~ /^$/ || $line =~ /^#/); + if ($line =~ /^\[(.+?)\]$/) { + # Section header + $current_section = $1; + $existing_config->{$current_section} ||= {}; + } + elsif ($line =~ /^([^=]+)=(.*)$/ && $current_section) { + # Key-value pair + my ($key, $value) = ($1, $2); + push(@{ $existing_config->{$current_section}{$key} }, + $value); + } + } + } + +# Merge the requested section changes into the existing override data. +foreach my $section (keys(%{$new_config})) { + my $has_values = 0; + foreach my $key (keys(%{ $new_config->{$section} })) { + my $values = $new_config->{$section}{$key}; + if (defined($values) && @$values) { + # Values replace the whole key, including repeated directives. + $existing_config->{$section}{$key} = $values; + $has_values = 1; + } + else { + # Undef or an empty list means the key should be removed. + delete($existing_config->{$section}{$key}); + } + } + + # Drop empty sections so the override remains compact. + delete($existing_config->{$section}) if (!$has_values); + } + +# Serialize the merged override in stable section/key order. +my $override_content = ""; +foreach my $section (sort(keys(%{$existing_config}))) { + $override_content .= "[$section]\n"; + foreach my $key (sort(keys(%{ $existing_config->{$section} }))) { + foreach my $value (@{ $existing_config->{$section}{$key} }) { + $override_content .= "$key=$value\n"; + } + } + $override_content .= "\n"; + } + +# Write the merged configuration back to the drop-in file. +lock_file($override_file); +write_file_contents($override_file, $override_content); +unlock_file($override_file); + +# Reload systemd to apply the changed drop-in. +system_logged("systemctl daemon-reload") == 0 || + error("Failed to reload systemd daemon: $!"); +} + +1; diff --git a/systemd/t/perlcritic.t b/systemd/t/perlcritic.t new file mode 100644 index 000000000..6536e8564 --- /dev/null +++ b/systemd/t/perlcritic.t @@ -0,0 +1,68 @@ +#!/usr/bin/perl +use strict; +use warnings; +use Test::More; + +BEGIN { + eval { require Perl::Critic; 1 } + or plan skip_all => 'Perl::Critic not installed'; +} + +use File::Find; + +# script_dir() +# Returns the directory containing this test file. +sub script_dir +{ + my $path = $0; + if ($path =~ m{^/}) { + $path =~ s{/[^/]+$}{}; + return $path; + } + my $cwd = `pwd`; + chomp($cwd); + if ($path =~ m{/}) { + $path =~ s{/[^/]+$}{}; + return $cwd.'/'.$path; + } + return $cwd; +} + +my $bindir = script_dir(); +my $module_dir = "$bindir/.."; +my $profile = "$bindir/../../.perlcriticrc"; +if (!-r $profile) { + plan skip_all => 'Perl::Critic profile not installed'; +} +chdir($module_dir) or die "chdir: $!"; + +my @files; +find( + sub { + return if -d; + return if -l; + return unless /\.(pl|cgi)\z/; + return if /\.info\.pl\z/; + push(@files, $File::Find::name); + }, + '.' +); + +@files = sort @files; +if (!@files) { + plan skip_all => 'no perl files to check'; +} + +my $critic = Perl::Critic->new( + -profile => $profile, +); + +foreach my $file (@files) { + my @violations = $critic->critique($file); + is(scalar @violations, 0, "$file perlcritic"); + if (@violations) { + diag join("", @violations); + } +} + +done_testing(); diff --git a/systemd/t/run-tests.t b/systemd/t/run-tests.t new file mode 100644 index 000000000..10c330a93 --- /dev/null +++ b/systemd/t/run-tests.t @@ -0,0 +1,2375 @@ +#!/usr/bin/perl +use strict; +use warnings; +no warnings 'redefine'; +no warnings 'once'; +use Test::More; +use Cwd qw(abs_path); +use File::Path qw(make_path); +use File::Temp qw(tempdir); + +# script_dir() +# Returns the directory containing this test file. +sub script_dir +{ + my $path = $0; + if ($path =~ m{^/}) { + $path =~ s{/[^/]+$}{}; + return $path; + } + my $cwd = `pwd`; + chomp($cwd); + if ($path =~ m{/}) { + $path =~ s{/[^/]+$}{}; + return $cwd.'/'.$path; + } + return $cwd; +} + +# write_test_file(file, data) +# Writes a fixture file. +sub write_test_file +{ + my ($file, $data) = @_; + open(my $fh, '>', $file) or die "$file: $!"; + print $fh $data; + close($fh); +} + +# slurp_test_file(file) +# Reads a fixture or source file as one scalar. +sub slurp_test_file +{ + my ($file) = @_; + open(my $fh, '<', $file) or die "$file: $!"; + local $/; + my $data = <$fh>; + close($fh); + return $data; +} + +my $bindir = script_dir(); +my $rootdir = abs_path("$bindir/../..") or die "rootdir: $!"; + +my $confdir = tempdir(CLEANUP => 1); +my $vardir = tempdir(CLEANUP => 1); +make_path("$confdir/systemd"); +write_test_file("$confdir/config", "os_type=generic-linux\nos_version=0\n"); +write_test_file("$confdir/var-path", "$vardir\n"); +write_test_file("$confdir/systemd/config", "desc=1\n"); +$ENV{'WEBMIN_CONFIG'} = $confdir; +$ENV{'WEBMIN_VAR'} = $vardir; +$ENV{'FOREIGN_MODULE_NAME'} = 'systemd'; +$ENV{'FOREIGN_ROOT_DIRECTORY'} = $rootdir; + +chdir("$bindir/..") or die "chdir: $!"; +unshift(@INC, "$bindir/.."); +require 'systemd-lib.pl'; ## no critic +our (%access, %config, %in, %text, %gconfig, $remote_user); + +%text = ( + %text, + yes => 'Yes', + no => 'No', + systemd_ejournal => 'journalctl missing', + systemd_euser => 'bad user', + systemd_euserhome => 'bad home', + systemd_euserunitfile => 'bad user unit file', + systemd_euserunitdir => 'bad user unit dir', + systemd_edropinfile => 'bad drop-in file', + systemd_ereadonly => 'runtime unit file', + systemd_ename => 'bad unit name', + systemd_egone => 'unit gone', + systemd_eclash => 'unit clash', + systemd_emountwhat => 'missing mount source', + systemd_emountname => 'bad mount name', + systemd_eautomountmount => 'bad matching mount', + systemd_eautomountname => 'bad automount name', + systemd_eautomountmode => 'bad automount mode', + systemd_everify => 'verify failed: $1', + systemd_type_service => 'Service', + systemd_type_timer => 'Timer', + systemd_type_socket => 'Socket', + systemd_type_path => 'Path', + systemd_type_target => 'Target', + systemd_type_mount => 'Mount', + systemd_type_automount => 'Automount', + systemd_type_swap => 'Swap', + systemd_type_slice => 'Slice', + systemd_type_scope => 'Scope', + systemd_type_device => 'Device', + systemd_tab_storage => 'Storage', + systemd_tab_resources => 'Resources', + systemd_tab_device => 'Devices', + index_unknown => 'Unknown', + log_modify => 'Modified unit $1', + log_create => 'Created unit $1', + log_delete => 'Deleted unit $1', + log_override => 'Created drop-in override for unit $1', + log_deleteoverride => 'Deleted drop-in override for unit $1', + log_status => 'Fetched status of unit $1', + log_props => 'Fetched properties of unit $1', + log_deps => 'Listed dependencies of unit $1', + log_logs => 'Fetched logs for unit $1', + log_massstart => 'Started units $1', + log_massstop => 'Stopped units $1', + log_massrestart => 'Restarted units $1', + log_massenable => 'Enabled units $1', + log_massdisable => 'Disabled units $1', + log_massmask => 'Masked units $1', + log_massunmask => 'Unmasked units $1', + mass_enostart => 'Start is not applicable for this unit type', + mass_enorestart => 'Restart is not applicable for this unit type', + log_user_modify => 'Modified user unit $1 for $2', + log_user_create => 'Created user unit $1 for $2', + log_user_delete => 'Deleted user unit $1 for $2', + log_user_override => 'Created drop-in override for user unit $1 for $2', + log_user_deleteoverride => 'Deleted user unit drop-in override $1 for $2', + log_user_status => 'Fetched status of user unit $1 for $2', + log_user_props => 'Fetched properties of user unit $1 for $2', + log_user_deps => 'Listed dependencies of user unit $1 for $2', + log_user_logs => 'Fetched logs for user unit $1 for $2', + log_user_massstart => 'Started user units $1 for $2', + log_user_massstop => 'Stopped user units $1 for $2', + log_user_massrestart => 'Restarted user units $1 for $2', + log_user_massenable => 'Enabled user units $1 for $2', + log_user_massdisable => 'Disabled user units $1 for $2', + log_user_massmask => 'Masked user units $1 for $2', + log_user_massunmask => 'Unmasked user units $1 for $2', + log_user_massdelete => 'Deleted user units $1 for $2', + log_user_linger => 'Set linger for user $1 to $2', +); + +is_deeply([ get_creatable_unit_types() ], + [ qw(service timer socket path target mount automount swap slice) ], + 'creatable unit types are stable'); +is_deeply([ get_creatable_unit_types(1) ], + [ qw(service timer socket path target slice) ], + 'user creatable unit types exclude privileged storage units'); +is_deeply([ get_list_unit_types() ], + [ qw(service timer socket path target mount automount swap slice scope device) ], + 'listed unit types are stable'); +ok(grep { $_ eq 'device' } get_unit_types(), + 'full unit type list includes generated systemd unit kinds'); +is_deeply([ get_index_tab_ids() ], + [ qw(service timer socket path target storage resources device user) ], + 'index tabs are stable'); +is(default_visible_tabs(), + 'service,timer,socket,path,target,storage,resources,device,user', + 'default visible tabs include every tab'); +{ + local $config{'visible_tabs'} = 'service,device'; + ok(tab_visible('service'), 'visible tab helper accepts configured tab'); + ok(!tab_visible('timer'), 'visible tab helper rejects hidden tab'); +} +ok(boot_state_changeable('enabled'), 'enabled unit boot state is editable'); +ok(boot_state_changeable('disabled'), 'disabled unit boot state is editable'); +ok(boot_state_changeable('Disabled'), 'boot state helper accepts systemd-style casing'); +ok(!boot_state_changeable('static'), 'static unit boot state is not editable'); +ok(!boot_state_changeable('masked'), 'masked unit boot state is not editable'); +ok(!boot_state_changeable('transient'), 'transient unit boot state is not editable'); +ok(!boot_state_changeable('disabled', 'session-2.scope'), + 'scope units do not show boot state toggles'); +ok(!boot_state_changeable('generated', 'dev-sda.device'), + 'device units do not show boot state toggles'); +ok(unit_file_editable({ + name => 'user.slice', + file => '/usr/lib/systemd/system/user.slice', + unitstate => 'static', + }), + 'persistent static unit files can still be edited'); +ok(!unit_file_editable({ + name => 'session-2.scope', + file => '/run/systemd/transient/session-2.scope', + unitstate => 'transient', + }), + 'transient scope files are read-only'); +ok(!unit_file_editable({ + name => 'custom.scope', + file => '/etc/systemd/system/custom.scope', + unitstate => 'disabled', + }), + 'scope unit files are read-only even with a persistent path'); +ok(!unit_file_editable({ + name => 'generated.service', + file => '/run/systemd/generator/generated.service', + unitstate => 'generated', + }), + 'generated unit files are read-only'); +{ + local $config{'show_runtime_units'} = 0; + ok(unit_visible_on_index({ + name => 'demo.service', + file => '/etc/systemd/system/demo.service', + unitstate => 'enabled', + }), + 'persistent units remain visible when runtime units are hidden'); + ok(!unit_visible_on_index({ + name => 'session-2.scope', + file => '/run/systemd/transient/session-2.scope', + unitstate => 'transient', + }), + 'transient units can be hidden from index tabs'); +} + +{ + ok(!systemd_acl_bool({ }, 'edit_user'), + 'missing granular ACL permissions are denied'); + ok(systemd_acl_bool({ edit_user => 1 }, 'edit_user'), + 'explicit granular ACL permissions are allowed'); + ok(systemd_can_view_system({ start => 1 }), + 'system action permission implies system scope visibility'); + ok(!systemd_can_view_system({ start_user => 1 }), + 'user action permission does not imply system scope visibility'); + ok(systemd_can_view_user_scope({ create_user => 1 }), + 'user action permission implies user scope visibility'); + ok(systemd_can_inspect({ view => 1, status => 1 }, 0), + 'status ACL allows system unit inspection'); + ok(systemd_can_inspect({ view_user => 1, status_user => 1 }, 1), + 'user status ACL allows user unit inspection'); + ok(!systemd_can_inspect({ view_user => 1, status => 1 }, 1), + 'system status ACL does not grant user unit inspection'); + ok(!systemd_can_logs({ view => 1, status => 1 }, 0), + 'status ACL does not grant log access'); + ok(systemd_can_runtime({ view_user => 1, start_user => 1 }, + 'start', 1), + 'runtime ACL allows user unit starts when user scope is visible'); + ok(!systemd_can_runtime({ view_user => 1, start => 1 }, 'start', 1), + 'system runtime ACL does not grant user unit starts'); + ok(!systemd_can_runtime({ view => 1, start_user => 1 }, 'start', 0), + 'user runtime ACL does not grant system unit starts'); + ok(!systemd_can_runtime({ view_user => 1, stop_user => 1 }, + 'start', 1), + 'one runtime action does not grant another'); + + my %only_acl = ( mode => 1, users => 'alice bob' ); + ok(systemd_acl_user_allowed(\%only_acl, 'alice'), + 'user ACL only-list accepts listed owner'); + ok(!systemd_acl_user_allowed(\%only_acl, 'carol'), + 'user ACL only-list rejects unlisted owner'); + ok(systemd_acl_user_allowed({ mode => 99 }, 'alice'), + 'invalid user ACL mode falls back to all users'); + is(systemd_acl_default_user({ mode => 1, users => 'alice' }), + 'alice', 'single-user allow-list supplies a default owner'); + is(systemd_acl_default_user({ mode => 1, users => 'alice bob' }), + undef, 'multi-user allow-list does not guess a default owner'); + my %except_acl = ( mode => 2, users => 'alice bob' ); + ok(!systemd_acl_user_allowed(\%except_acl, 'alice'), + 'user ACL except-list rejects listed owner'); + ok(systemd_acl_user_allowed(\%except_acl, 'carol'), + 'user ACL except-list accepts unlisted owner'); + local $remote_user = 'alice'; + ok(systemd_acl_user_allowed({ mode => 3 }, 'alice'), + 'current Webmin user ACL accepts matching owner'); + is(systemd_acl_default_user({ mode => 3 }), 'alice', + 'current Webmin user mode supplies a default owner'); + ok(!systemd_acl_user_allowed({ mode => 3 }, 'bob'), + 'current Webmin user ACL rejects different owner'); + ok(systemd_can_create({ view_user => 1, create_user => 1, + mode => 3 }, 1, 'alice'), + 'user create ACL honors current Webmin user owner'); + ok(!systemd_can_create({ view_user => 1, create_user => 1, + mode => 3 }, 1, 'bob'), + 'user create ACL rejects disallowed owner'); + ok(systemd_can_manual({ manual_user => 1, mode => 1, + users => 'alice' }, + { scope => 'user', user => 'alice' }), + 'manual user file ACL honors owner filter'); + ok(!systemd_can_manual({ manual_user => 1, mode => 1, + users => 'alice' }, + { scope => 'user', user => 'bob' }), + 'manual user file ACL rejects disallowed owner'); + + my %virtualmin_acl = systemd_user_unit_acl('alice'); + is($virtualmin_acl{'noconfig'}, 1, + 'Virtualmin user-unit ACL preset disables module config access'); + is($virtualmin_acl{'mode'}, 1, + 'Virtualmin user-unit ACL preset limits access to named users'); + is($virtualmin_acl{'users'}, 'alice', + 'Virtualmin user-unit ACL preset stores the allowed owner'); + foreach my $key (qw(view status logs start stop restart boot mask create + edit delete dropin manual reload backup)) { + ok(!$virtualmin_acl{$key}, + "Virtualmin user-unit ACL preset denies system $key"); + } + foreach my $key (qw(view_user status_user logs_user start_user stop_user + restart_user boot_user create_user edit_user + delete_user dropin_user manual_user linger)) { + ok($virtualmin_acl{$key}, + "Virtualmin user-unit ACL preset allows $key"); + } + ok(!$virtualmin_acl{'mask_user'}, + 'Virtualmin user-unit ACL preset denies user-unit masking'); + ok(systemd_can_create(\%virtualmin_acl, 1, 'alice'), + 'Virtualmin user-unit ACL preset can create allowed user units'); + ok(!systemd_can_mask(\%virtualmin_acl, 1, 'alice'), + 'Virtualmin user-unit ACL preset cannot mask user units'); + ok(!systemd_can_create(\%virtualmin_acl, 1, 'bob'), + 'Virtualmin user-unit ACL preset rejects other user owners'); + ok(!systemd_can_create(\%virtualmin_acl, 0), + 'Virtualmin user-unit ACL preset cannot create system units'); + + my %safe_acl = ( mode => 3, + view => 0, view_user => 1, + create => 0, create_user => 1, + edit => 0, edit_user => 1, + mask => 0, mask_user => 0 ); + ok(systemd_can_create(\%safe_acl, 1, 'alice'), + 'safe Webmin user ACL can create own user units'); + ok(!systemd_can_create(\%safe_acl, 1, 'bob'), + 'safe Webmin user ACL rejects other user managers'); + ok(!systemd_can_create(\%safe_acl, 0), + 'safe Webmin user ACL cannot create system units'); + ok(!systemd_can_mask(\%safe_acl, 1, 'alice'), + 'safe Webmin user ACL does not permit user-unit masking'); + + my @me = getpwuid($<); + if (@me) { + my ($name, $uid, $gid) = ($me[0], $me[2], $me[3]); + ok(systemd_acl_user_allowed({ mode => 4, uidmin => $uid, + uidmax => $uid }, $name), + 'UID range ACL accepts matching owner'); + ok(systemd_acl_user_allowed({ mode => 5, users => $gid }, $name), + 'primary group ACL accepts matching owner'); + } +} + +is_deeply([ split_exec_commands(" one \r\n\n two \n") ], + [ 'one', 'two' ], + 'multi-line command fields are trimmed and split'); +is(shell_exec_command('/bin/sh', q{echo 'one'}), + q{/bin/sh -c 'echo '\''one'\'''}, + 'shell command escapes single quotes'); +is(format_exec_command('/bin/sh', 'echo ok'), 'echo ok', + 'plain command does not need a shell'); +is(format_exec_command('/bin/sh', 'echo ok > /tmp/out'), + q{/bin/sh -c 'echo ok > /tmp/out'}, + 'redirected command is wrapped in a shell'); +is(clean_unit_value(" a\0b\n c\r "), 'ab c', + 'scalar unit values lose nulls and line breaks'); +is(clean_unit_body(" A\0\r\nB\n "), "A\nB", + 'unit body preserves newlines but removes nulls and carriage returns'); +is(quote_unit_word(q{A "B" \ C}), '"A \"B\" \\\\ C"', + 'Environment word quoting escapes quotes and backslashes'); +is_deeply([ format_environment_directives( + q{NODE_ENV=production APP_NAME="My App"}) ], + [ "Environment=\"NODE_ENV=production\"\n", + "Environment=\"APP_NAME=My App\"\n" ], + 'environment directives split shell-style words'); +is(format_output_value('/var/log/demo.log'), 'append:/var/log/demo.log', + 'absolute log paths append by default'); +is(format_output_value('journal'), 'journal', + 'systemd log targets are preserved'); +is(format_output_value(" \n "), undef, + 'blank log target is ignored'); + +ok(valid_duration('30s'), 'duration accepts seconds'); +ok(valid_duration('1min 30s'), 'duration accepts compound values'); +ok(valid_duration('infinity'), 'duration accepts infinity'); +ok(!valid_duration('soon'), 'duration rejects arbitrary words'); +ok(valid_path('/run/app.pid', 0, 0), 'absolute path is valid'); +ok(valid_path('-/etc/default/app', 1, 0), 'dash-prefixed path can be allowed'); +ok(valid_path('~/app', 0, 1), 'tilde path can be allowed'); +ok(!valid_path('relative/path', 0, 0), 'relative path is invalid by default'); +ok(!valid_path("/tmp/a b", 0, 0), 'path with spaces is invalid'); +is(path_unit_name('/mnt/data', 'mount'), 'mnt-data.mount', + 'mount unit name is derived from path'); +is(path_unit_name('/mnt/data', 'automount'), 'mnt-data.automount', + 'automount unit name is derived from path'); +is(path_unit_name('/', 'mount'), '-.mount', + 'root mount unit name is derived from path'); +is(path_unit_name('/mnt/data', 'service'), undef, + 'path-derived unit names are limited to mount-like types'); +ok(valid_output('append:/var/log/app.log'), 'append output target is valid'); +ok(valid_output('/var/log/app.log'), 'absolute output path is valid'); +ok(!valid_output("journal\nbad"), 'output target rejects newlines'); + +my $work = tempdir(CLEANUP => 1); +my $service_file = "$work/demo.service"; +my $service_data = render_unit({ + type => 'service', + description => "Demo\nService", + service => { + start => "/usr/bin/start-one\n/usr/bin/start-two", + stop => "/usr/bin/stop", + reload => "/usr/bin/reload", + pidfile => "/run/demo.pid\n", + }, + options => { + before => "network.target\nignored.target", + after => 'network-online.target', + wants => 'network-online.target', + requires => 'postgresql.service', + conflicts => 'old-demo.service', + onfailure => 'notify@%n.service', + onsuccess => 'report.service', + startpre => "/usr/bin/pre\n/usr/bin/pre2", + startpost => '/usr/bin/post', + stoppost => '/usr/bin/cleanup', + env => q{A=1 B="two words"}, + envfile => '-/etc/default/demo', + user => 'demo', + group => 'demo', + killmode => 'mixed', + workdir => '/srv/demo', + restart => 'on-failure', + restartsec => '5s', + watchdogsec => '30s', + timeout => '99s', + timeoutstartsec => '15s', + timeoutstopsec => '10s', + limitnofile => '65535', + logstd => '/var/log/demo.log', + logerr => 'journal', + syslogid => 'demo', + nonewprivs => 1, + privatetmp => 1, + protectsystem => 'full', + readwritepaths => '/var/lib/demo', + wantedby => 'multi-user.target', + }, +}); +write_unit_file($service_file, $service_data); +my $service = slurp_test_file($service_file); +like($service, qr/^\[Unit\]$/m, 'service file has Unit section'); +like($service, qr/^Description=Demo Service$/m, 'description is single-line'); +like($service, qr/^Before=network\.target ignored\.target$/m, + 'relationship scalar is cleaned'); +like($service, qr/^Type=oneshot$/m, 'multiple start commands default to oneshot'); +is(() = $service =~ /^ExecStart=/mg, 2, + 'oneshot multi-command service emits one ExecStart per command'); +like($service, qr/^ExecStartPre=\/usr\/bin\/pre$/m, 'start pre hook written'); +like($service, qr/^ExecStartPost=\/usr\/bin\/post$/m, 'start post hook written'); +like($service, qr/^ExecStopPost=\/usr\/bin\/cleanup$/m, 'stop post hook written'); +like($service, qr/^ExecStop=\/usr\/bin\/stop$/m, 'stop command written'); +like($service, qr/^ExecReload=\/usr\/bin\/reload$/m, 'reload command written'); +like($service, qr/^Environment="A=1"$/m, 'first environment variable written'); +like($service, qr/^Environment="B=two words"$/m, + 'quoted environment variable written'); +like($service, qr/^EnvironmentFile=-\/etc\/default\/demo$/m, + 'environment file written'); +like($service, qr/^User=demo$/m, 'system service user written'); +like($service, qr/^Group=demo$/m, 'system service group written'); +like($service, qr/^Restart=on-failure$/m, 'restart policy written'); +like($service, qr/^RestartSec=5s$/m, 'restart delay written'); +like($service, qr/^TimeoutStartSec=15s$/m, 'startup timeout uses TimeoutStartSec'); +unlike($service, qr/^TimeoutSec=/m, 'legacy TimeoutSec is not emitted'); +like($service, qr/^TimeoutStopSec=10s$/m, 'shutdown timeout written'); +like($service, qr/^StandardOutput=append:\/var\/log\/demo\.log$/m, + 'absolute stdout path appends'); +like($service, qr/^StandardError=journal$/m, 'stderr target written'); +like($service, qr/^NoNewPrivileges=yes$/m, 'NoNewPrivileges written'); +like($service, qr/^PrivateTmp=yes$/m, 'PrivateTmp written'); +like($service, qr/^ProtectSystem=full$/m, 'ProtectSystem written'); +like($service, qr/^ReadWritePaths=\/var\/lib\/demo$/m, + 'ReadWritePaths written'); +like($service, qr/^WantedBy=multi-user\.target$/m, 'install target written'); + +my $simple_file = "$work/simple.service"; +my $simple_data = render_unit({ + type => 'service', + description => 'Simple', + service => { + start => "/bin/one\n/bin/two", + }, + options => { + type => 'simple', + }, +}); +write_unit_file($simple_file, $simple_data); +my $simple = slurp_test_file($simple_file); +like($simple, qr/^Type=simple$/m, 'explicit service type preserved'); +is(() = $simple =~ /^ExecStart=/mg, 1, + 'non-oneshot multi-command service emits one shell ExecStart'); +like($simple, qr/^ExecStart=.* -c '\/bin\/one; \/bin\/two'$/m, + 'non-oneshot multi-command service joins commands through shell'); + +my $timer_file = "$work/demo.timer"; +my $timer_data = render_unit({ + type => 'timer', + description => "Timer\nDesc", + body => "OnCalendar=daily\nPersistent=true\n", + options => { + wantedby => 'timers.target', + after => 'network.target', + }, +}); +write_unit_file($timer_file, $timer_data); +my $timer = slurp_test_file($timer_file); +like($timer, qr/^\[Unit\]\nDescription=Timer Desc\nAfter=network\.target/ms, + 'non-service unit writes common Unit settings'); +like($timer, qr/^\[Timer\]\nOnCalendar=daily\nPersistent=true/m, + 'timer body is wrapped in Timer section'); +like($timer, qr/^\[Install\]\nWantedBy=timers\.target/m, + 'non-service install target is written'); + +my $structured_timer = render_timer_body({ + oncalendar => 'Mon..Fri 09:00', + onbootsec => '5min', + onunitactivesec => '1h', + persistent => 1, + randomizeddelaysec => '10min', + accuracysec => '1min', + unit => 'demo.service', +}); +like($structured_timer, qr/^OnCalendar=Mon\.\.Fri 09:00$/m, + 'structured timer writes OnCalendar'); +like($structured_timer, qr/^OnBootSec=5min$/m, + 'structured timer writes OnBootSec'); +like($structured_timer, qr/^OnUnitActiveSec=1h$/m, + 'structured timer writes OnUnitActiveSec'); +like($structured_timer, qr/^Persistent=yes$/m, + 'structured timer writes Persistent'); +like($structured_timer, qr/^RandomizedDelaySec=10min$/m, + 'structured timer writes RandomizedDelaySec'); +like($structured_timer, qr/^AccuracySec=1min$/m, + 'structured timer writes AccuracySec'); +like($structured_timer, qr/^Unit=demo\.service$/m, + 'structured timer writes activated unit'); + +my $structured_socket = render_socket_body({ + listenstream => '127.0.0.1:8080', + listendatagram => '10514', + listenfifo => '/run/demo.fifo', + accept => 1, + user => 'demo', + group => 'demo', + mode => '0660', + service => 'demo.service', +}); +like($structured_socket, qr/^ListenStream=127\.0\.0\.1:8080$/m, + 'structured socket writes stream listener'); +like($structured_socket, qr/^ListenDatagram=10514$/m, + 'structured socket writes datagram listener'); +like($structured_socket, qr/^ListenFIFO=\/run\/demo\.fifo$/m, + 'structured socket writes FIFO listener'); +like($structured_socket, qr/^Accept=yes$/m, + 'structured socket writes Accept'); +like($structured_socket, qr/^SocketUser=demo$/m, + 'structured socket writes SocketUser'); +like($structured_socket, qr/^SocketGroup=demo$/m, + 'structured socket writes SocketGroup'); +like($structured_socket, qr/^SocketMode=0660$/m, + 'structured socket writes SocketMode'); +like($structured_socket, qr/^Service=demo\.service$/m, + 'structured socket writes service target'); + +my $structured_path = render_path_body({ + exists => '/run/demo.ready', + existsglob => '/run/demo/*.ready', + changed => '/etc/demo.conf', + modified => '/etc/demo.d', + directorynotempty => '/var/spool/demo', + makedirectory => 1, + unit => 'reload-demo.service', +}); +like($structured_path, qr/^PathExists=\/run\/demo\.ready$/m, + 'structured path writes PathExists'); +like($structured_path, qr/^PathExistsGlob=\/run\/demo\/\*\.ready$/m, + 'structured path writes PathExistsGlob'); +like($structured_path, qr/^PathChanged=\/etc\/demo\.conf$/m, + 'structured path writes PathChanged'); +like($structured_path, qr/^PathModified=\/etc\/demo\.d$/m, + 'structured path writes PathModified'); +like($structured_path, qr/^DirectoryNotEmpty=\/var\/spool\/demo$/m, + 'structured path writes DirectoryNotEmpty'); +like($structured_path, qr/^MakeDirectory=yes$/m, + 'structured path writes MakeDirectory'); +like($structured_path, qr/^Unit=reload-demo\.service$/m, + 'structured path writes activated unit'); + +my $mount_data = render_unit({ + type => 'mount', + description => 'Data mount', + body => render_mount_body('/dev/disk/by-label/data', '/data', + 'xfs', 'defaults'), + options => { + wantedby => 'local-fs.target', + }, +}); +like($mount_data, qr/^\[Mount\]\nWhat=\/dev\/disk\/by-label\/data\nWhere=\/data/m, + 'mount body is wrapped in Mount section'); +like($mount_data, qr/^Options=defaults$/m, 'mount options are written'); +is(mount_where_from_data($mount_data), '/data', + 'mount Where path can be read from unit data'); +like($mount_data, qr/^WantedBy=local-fs\.target$/m, + 'mount install target is written'); + +my $automount_data = render_unit({ + type => 'automount', + description => 'Data automount', + body => render_automount_body('/data', '5min', '0755'), + options => { + wantedby => 'local-fs.target', + }, +}); +like($automount_data, + qr/^\[Automount\]\nWhere=\/data\nTimeoutIdleSec=5min\nDirectoryMode=0755/m, + 'automount body is wrapped in Automount section'); + +my $swap_body = render_swap_body({ + what => '/swapfile', + priority => '10', + options => 'discard', + timeoutsec => '30s', +}); +like($swap_body, qr/^What=\/swapfile$/m, + 'structured swap writes What'); +like($swap_body, qr/^Priority=10$/m, + 'structured swap writes Priority'); +like($swap_body, qr/^Options=discard$/m, + 'structured swap writes Options'); +like($swap_body, qr/^TimeoutSec=30s$/m, + 'structured swap writes TimeoutSec'); + +my $slice_data = render_unit({ + type => 'slice', + description => 'Work slice', + body => '', + options => { + wantedby => 'slices.target', + }, +}); +unlike($slice_data, qr/^\[Slice\]$/m, + 'empty slice body does not emit an empty Slice section'); +like($slice_data, qr/^WantedBy=slices\.target$/m, + 'slice install target is written'); +my $slice_body = render_slice_body({ + cpuweight => '200', + memorymax => '512M', + tasksmax => '500', + ioweight => '300', +}); +like($slice_body, qr/^CPUWeight=200$/m, + 'structured slice writes CPUWeight'); +like($slice_body, qr/^MemoryMax=512M$/m, + 'structured slice writes MemoryMax'); +like($slice_body, qr/^TasksMax=500$/m, + 'structured slice writes TasksMax'); +like($slice_body, qr/^IOWeight=300$/m, + 'structured slice writes IOWeight'); + +ok(valid_unit_name('demo.service'), 'service unit name is valid'); +ok(valid_unit_name('demo@one.service'), 'instance unit name is valid'); +ok(!valid_unit_name('demo@.service'), 'template unit name is rejected'); +ok(!valid_unit_name('../demo.service'), 'path traversal unit name is rejected'); +ok(valid_unit_name('demo.mount'), 'known storage unit name is valid'); +ok(valid_creatable_unit_name('demo.mount'), + 'storage unit name is valid for creation'); +ok(valid_creatable_unit_name('demo.slice'), + 'slice unit name is valid for creation'); +ok(!valid_creatable_unit_name('demo.mount', 1), + 'storage unit name is not valid for user creation'); +ok(valid_creatable_unit_name('demo.slice', 1), + 'slice unit name is valid for user creation'); +ok(!valid_creatable_unit_name('demo.device'), + 'generated device units are not creatable'); +ok(valid_unit_file_name('demo.mount'), + 'manual unit filename accepts storage unit types'); +ok(valid_unit_file_name('demo@.service'), + 'manual unit filename accepts template unit files'); +is(get_unit_type_from_name('demo.socket'), 'socket', + 'unit type is detected from suffix'); +is(get_unit_type_from_name('demo.unknown'), undef, + 'unknown unit type suffix is ignored'); +ok(unit_startable('demo.service'), 'service unit can be started'); +ok(unit_restartable('demo.service'), 'service unit can be restarted'); +ok(!unit_startable('session-2.scope'), 'scope unit is not startable'); +ok(!unit_restartable('session-2.scope'), 'scope unit is not restartable'); +ok(!unit_startable('dev-sda.device'), 'device unit is not startable'); +ok(!unit_restartable('dev-sda.device'), 'device unit is not restartable'); +{ + my $base = abs_path($work); + my $local_root = "$base/root-systemd"; + my $real_root = "$base/usr-lib/systemd/system"; + my $link_parent = "$base/lib"; + my $link_root = "$link_parent/systemd/system"; + make_path($local_root, $real_root); + symlink("$base/usr-lib", $link_parent) or die "symlink $link_parent: $!"; + local *main::get_system_unit_file_root_candidates = sub { + return ($local_root, $link_root, $real_root); + }; + is_deeply([ get_system_unit_file_roots() ], + [ $local_root, $real_root ], + 'system unit roots skip symlink aliases'); + local $config{'manual_vendor_units'} = 0; + local *main::local_unit_file_root = sub { + my ($root) = @_; + return $root eq $local_root; + }; + is_deeply([ get_system_unit_file_roots() ], + [ $local_root ], + 'system unit roots can omit vendor directories'); +} +is(get_unit_section('path'), 'Path', 'path section name'); +is(get_unit_section('mount'), 'Mount', 'mount section name'); +is(get_unit_section('slice'), 'Slice', 'slice section name'); +is(get_default_install_target('service', 0), 'multi-user.target', + 'system service default target'); +is(get_default_install_target('service', 1), 'default.target', + 'user service default target'); +is(get_default_install_target('timer', 0), 'timers.target', + 'timer default target'); +is(get_default_install_target('mount', 0), 'local-fs.target', + 'system mount default target'); +is(get_default_install_target('swap', 0), 'swap.target', + 'system swap default target'); +is(get_default_install_target('slice', 0), 'slices.target', + 'slice default target'); +is(index_url('demo.timer', 0, undef), 'index.cgi?mode=timer', + 'system unit return URL selects type tab'); +is(index_url('mnt-data.mount', 0, undef), 'index.cgi?mode=storage', + 'system storage unit return URL selects storage tab'); +is(index_url('session-1.scope', 0, undef), 'index.cgi?mode=resources', + 'system resource unit return URL selects resources tab'); +is(index_url('dev-sda.device', 0, undef), 'index.cgi?mode=device', + 'system device unit return URL selects devices tab'); +is(index_url('demo.service', 1, 'alice'), + 'index.cgi?mode=user&scope=user&unituser=alice', + 'user unit return URL selects user tab and owner'); +ok(!get_user_details("bad/user"), + 'user details reject names that cannot be Unix users'); +{ + my $login = getpwuid($>); + SKIP: { + skip('current UID has no passwd entry', 2) if (!$login); + my $details = get_user_details($login); + ok($details && $details->{'home'} =~ m{^/}, + 'user details resolve current Unix user'); + is(get_user_root($login), + $details->{'home'}.'/.config/systemd/user', + 'user root is derived from Unix home directory'); + } +} +like(get_unit_root(), qr{^/(etc|usr/lib|lib)/systemd/system$}, + 'systemd root falls back to a canonical unit directory'); +{ + local *main::list_units = sub { + return ( { name => 'demo.service' } ); + }; + ok(!is_unit('demo'), 'bare service name is not treated as a unit'); + ok(is_unit('demo.service'), 'typed service name matches systemd unit'); + ok(!is_unit('missing'), 'unknown service name does not match'); +} + +{ + my @cmds; + local *main::backquote_logged = sub { + push(@cmds, $_[0]); + $? = 0; + return 'ok'; + }; + local *main::backquote_command = sub { return '' }; + my ($ok, $out) = start_unit('evil;touch.service'); + ok(!$ok, 'system service start rejects invalid unit names'); + is($out, 'bad unit name', 'invalid unit returns validation error'); + is(scalar(@cmds), 0, 'invalid system unit name builds no command'); + ($ok, $out) = start_unit('demo.service'); + ok($ok, 'system service start reports success from command exit'); + is($out, 'ok', 'system service start returns command output'); + like($cmds[0], qr/systemctl start demo\\.service/, + 'system service start quotes unit names'); + ($ok, $out) = stop_unit('demo.service'); + like($cmds[-1], qr/systemctl stop demo\\.service/, + 'stop command uses systemctl'); + ($ok, $out) = restart_unit('demo.service'); + like($cmds[-1], qr/systemctl restart demo\\.service/, + 'restart command uses systemctl'); + ($ok, $out) = reload_unit('demo.service'); + like($cmds[-1], qr/systemctl reload demo\\.service/, + 'reload command uses systemctl'); + ($ok, $out) = status_unit('demo.service'); + like($cmds[-1], qr/systemctl --full --no-pager status demo\\.service/, + 'status command uses full non-paged output'); + ($ok, $out) = properties_unit('demo.service'); + like($cmds[-1], qr/systemctl --full --no-pager show demo\\.service/, + 'properties command uses full non-paged output'); + ($ok, $out) = dependencies_unit('demo.service'); + like($cmds[-1], + qr/systemctl --full --no-pager list-dependencies demo\\.service/, + 'dependency command uses full non-paged output'); +} + +{ + my @cmds; + my $reloaded = 0; + local *main::backquote_logged = sub { + push(@cmds, $_[0]); + $? = 0; + return 'ok'; + }; + local *main::reload_manager = sub { $reloaded++ }; + my ($ok) = enable_unit('demo.timer'); + ok($ok, 'enable_unit reports success'); + like($cmds[0], qr/systemctl enable demo\\.timer/, + 'enable command is quoted'); + is($reloaded, 1, 'enable reloads systemd once'); + ($ok) = disable_unit('demo.timer'); + ok($ok, 'disable_unit reports success'); + is($reloaded, 2, 'disable reloads systemd once'); + ($ok) = mask_unit('demo.timer'); + ok($ok, 'mask_unit reports success'); + like($cmds[-1], qr/systemctl mask demo\\.timer/, + 'mask command is quoted'); + is($reloaded, 3, 'mask reloads systemd once'); + ($ok) = unmask_unit('demo.timer'); + ok($ok, 'unmask_unit reports success'); + like($cmds[-1], qr/systemctl unmask demo\\.timer/, + 'unmask command is quoted'); + is($reloaded, 4, 'unmask reloads systemd once'); +} + +{ + my $reloaded = 0; + my $message = "The unit files have no installation config. ". + "This means they are not meant to be enabled or disabled."; + local *main::backquote_logged = sub { + $? = 0; + return $message; + }; + local *main::reload_manager = sub { $reloaded++ }; + my ($ok, $out) = disable_unit('static.target'); + ok(!$ok, 'disable_unit reports no change for static units'); + ok(startup_change_skipped($out), + 'static enable-disable message is detected'); + is($reloaded, 1, 'static disable still reloads after command'); + + local *main::run_user_systemctl = sub { + return (1, $message); + }; + local *main::check_user_unit_dirs = sub { + return (1, undef); + }; + ($ok, $out) = enable_user_unit('alice', 'static.target'); + ok(!$ok, 'user enable reports no change for static units'); + ok(startup_change_skipped($out), + 'user static enable-disable message is detected'); +} + +{ + my @cmds; + local *main::has_command = sub { $_[0] eq 'journalctl' ? '/bin/journalctl' : undef }; + local *main::backquote_logged = sub { + push(@cmds, $_[0]); + $? = 0; + return "logs"; + }; + local $config{'logs_lines'} = 123; + my ($ok, $out) = logs_unit('demo.service'); + ok($ok, 'logs_unit reports success'); + is($out, 'logs', 'logs_unit returns output'); + like($cmds[0], qr/\\\/bin\\\/journalctl --no-pager --unit demo\\.service --lines 123/, + 'journalctl command quotes unit name and uses configured line count'); + unlike($cmds[0], qr/--boot/, 'journalctl omits boot filter by default'); + + local $config{'logs_current_boot'} = 1; + logs_unit('demo.service'); + like($cmds[-1], qr/--boot/, 'journalctl adds boot filter when configured'); +} + +{ + local *main::backquote_command = sub { return "MainPID=1234\n" }; + is(get_unit_pid('demo.service'), 1234, 'unit PID is parsed'); +} + +{ + local *main::backquote_logged = sub { + $? = 0; + return "active\n"; + }; + ok(is_active('demo.service'), 'active unit returns true'); + my ($rv, $out) = is_active('demo.service'); + is($rv, 0, 'active command exit code returned in list context'); + is($out, 'active', 'active command output is trimmed'); +} + +{ + my @cmds; + local *main::has_command = sub { $_[0] eq 'systemctl' ? '/bin/systemctl' : undef }; + local *main::system_logged = sub { + push(@cmds, $_[0]); + return 0; + }; + reload_manager(); + is($cmds[0], 'systemctl daemon-reload >/dev/null 2>&1', + 'reload_manager reloads through systemctl when available'); +} + +{ + my $root = "$work/system-root"; + make_path($root); + my $reloaded = 0; + local *main::get_unit_root = sub { return $root }; + local *main::reload_manager = sub { $reloaded++ }; + local *main::has_command = sub { return }; + my ($ok) = create_system_unit( + 'created.service', + render_unit({ + type => 'service', + description => 'Created', + service => { + start => '/bin/true', + }, + })); + ok($ok, 'create_system_unit reports service creation success'); + ok(-f "$root/created.service", 'create_system_unit writes service file'); + ($ok) = create_system_unit( + 'created.timer', + render_unit({ + type => 'timer', + description => 'Created timer', + body => 'OnCalendar=daily', + })); + ok($ok, 'create_system_unit reports timer creation success'); + ok(-f "$root/created.timer", 'create_system_unit writes timer file'); + is($reloaded, 2, 'system unit creation reloads systemd'); + + write_test_file("$root/demo", "bare"); + write_test_file("$root/demo.service", "typed"); + ($ok) = delete_system_unit('demo'); + ok(!$ok, 'delete_system_unit rejects bare service name'); + ok(-e "$root/demo", 'delete_system_unit leaves suffix-less file alone'); + ok(-e "$root/demo.service", 'delete_system_unit leaves typed file after bare rejection'); + ($ok) = delete_system_unit('demo.service'); + ok($ok, 'delete_system_unit accepts typed service name'); + ok(!-e "$root/demo.service", 'delete_system_unit removes service file'); + my $out; + ($ok, $out) = delete_system_unit('demo.service'); + ok(!$ok, 'delete_system_unit rejects already-missing unit'); + is($out, $text{'systemd_egone'}, + 'delete_system_unit reports stale missing unit'); +} + +{ + my $local_root = "$work/local-units"; + my $packaged_root = "$work/packaged-units"; + make_path($local_root, $packaged_root); + write_test_file("$local_root/demo.service", "[Unit]\nDescription=Demo\n"); + write_test_file("$local_root/local.path", ""); + write_test_file("$local_root/work.slice", ""); + write_test_file("$local_root/template@.service", ""); + make_path("$local_root/demo.service.d"); + write_test_file("$local_root/demo.service.d/00-local.conf", + "[Service]\nRestart=always\n"); + write_test_file("$packaged_root/vendor.socket", ""); + write_test_file("$packaged_root/vendor.mount", ""); + my @commands; + local @main::list_units_cache = (); + local *main::get_system_unit_file_roots = sub { + return ($local_root, $packaged_root); + }; + local *main::get_system_dropin_roots = sub { + return ($local_root); + }; + local *main::list_all_user_units = sub { return ( ) }; + local *main::get_unit_root = sub { + my ($name, $packaged) = @_; + return $packaged ? $packaged_root : $local_root; + }; + local *main::backquote_command = sub { + my ($cmd) = @_; + push(@commands, $cmd); + $? = 0; + return "demo.service loaded active running Demo\n". + "dev-sda.device loaded active plugged Disk\n". + "session-2.scope loaded active running Session\n". + "bad;unit.service loaded inactive dead Bad\n" + if ($cmd =~ /list-units/); + return "late.timer disabled\n" + if ($cmd =~ /list-unit-files/); + return join("\n", + "Id=demo.service", + "Description=Demo Service", + "UnitFileState=enabled", + "ActiveState=active", + "SubState=running", + "ExecMainPID=777", + "FragmentPath=$local_root/demo.service", + "", + "Id=local.path", + "Description=Local Path", + "UnitFileState=disabled", + "ActiveState=inactive", + "SubState=dead", + "ExecMainPID=0", + "FragmentPath=$local_root/local.path", + "", + "Id=vendor.socket", + "Description=Vendor Socket", + "UnitFileState=static", + "ActiveState=inactive", + "SubState=dead", + "ExecMainPID=0", + "FragmentPath=$packaged_root/vendor.socket", + "", + "Id=vendor.mount", + "Description=Vendor Mount", + "UnitFileState=static", + "ActiveState=inactive", + "SubState=dead", + "ExecMainPID=0", + "FragmentPath=$packaged_root/vendor.mount", + "", + "Id=work.slice", + "Description=Work Slice", + "UnitFileState=static", + "ActiveState=active", + "SubState=active", + "ExecMainPID=0", + "FragmentPath=$local_root/work.slice", + "", + "Id=session-2.scope", + "Description=Session Scope", + "UnitFileState=transient", + "ActiveState=active", + "SubState=running", + "ExecMainPID=0", + "FragmentPath=", + "", + "Id=dev-sda.device", + "Description=Disk Device", + "UnitFileState=generated", + "ActiveState=active", + "SubState=plugged", + "ExecMainPID=0", + "FragmentPath=", + "", + "Id=late.timer", + "Description=Late Timer", + "UnitFileState=disabled", + "ActiveState=inactive", + "SubState=dead", + "ExecMainPID=0", + "FragmentPath=$local_root/late.timer", + "", + "Id=legacy.service", + "Description=LSB: Legacy", + "UnitFileState=enabled", + "ActiveState=active", + "SubState=running", + "ExecMainPID=1", + "FragmentPath=$local_root/legacy.service", + "") if ($cmd =~ /systemctl show/); + return ""; + }; + local *main::has_command = sub { return }; + my @units = list_units(); + my ($show_command) = grep { /systemctl show/ } @commands; + my %by = map { $_->{'name'} => $_ } @units; + ok($by{'demo.service'}, 'list_units includes active units'); + ok($by{'local.path'}, 'list_units includes local unit files'); + ok($by{'vendor.socket'}, 'list_units includes packaged listed types'); + ok($by{'vendor.mount'}, 'list_units includes packaged storage units'); + ok($by{'work.slice'}, 'list_units includes resource-control units'); + ok($by{'session-2.scope'}, 'list_units includes transient scopes'); + ok($by{'dev-sda.device'}, 'list_units includes device units'); + is($by{'dev-sda.device'}->{'file'}, '', + 'generated device units do not get fake editable files'); + ok($by{'late.timer'}, 'list_units includes disabled unit files'); + ok(!$by{'legacy.service'}, 'list_units filters LSB wrappers'); + ok(!$by{'template@.service'}, 'list_units filters templates'); + like($show_command, qr/demo\\.service/, + 'list_units quotes names before systemctl show'); + unlike($show_command, qr/bad;unit/, + 'list_units filters invalid names before systemctl show'); + is($by{'demo.service'}->{'boot'}, 1, 'enabled unit boot status parsed'); + is($by{'vendor.socket'}->{'boot'}, 2, 'static unit boot status parsed'); + is($by{'demo.service'}->{'status'}, 1, 'active unit status parsed'); + is($by{'demo.service'}->{'unitstate'}, 'enabled', + 'enabled unit file state is preserved'); + is($by{'vendor.socket'}->{'unitstate'}, 'static', + 'static unit file state is preserved'); + is($by{'demo.service'}->{'runtime'}, 'active', + 'runtime active state is preserved'); + is($by{'demo.service'}->{'substate'}, 'running', + 'runtime sub-state is preserved'); + my %manual = map { $_->{'file'}, $_ } list_manual_unit_files(); + ok($manual{"$local_root/template@.service"}, + 'manual unit files include templates'); + ok($manual{"$packaged_root/vendor.mount"}, + 'manual unit files include vendor non-tab unit types'); + ok($manual{"$local_root/demo.service.d/00-local.conf"}, + 'manual unit files include system drop-in override files'); + is($manual{"$local_root/demo.service.d/00-local.conf"}->{'kind'}, + 'dropin', 'manual drop-in descriptor is marked'); + ok(!manual_system_unit_file_safe("$local_root/../escape.service"), + 'manual system unit file safety rejects traversal'); + my $manual_info = manual_unit_file("$local_root/local.path"); + ok($manual_info, 'manual_unit_file returns allowed file descriptor'); + my ($ok, $err) = write_manual_unit_file($manual_info, + "[Path]\nPathExists=/tmp\n"); + ok($ok, 'write_manual_unit_file writes system unit files'); + is(read_manual_unit_file($manual_info), "[Path]\nPathExists=/tmp\n", + 'read_manual_unit_file reads system unit files'); + my $manual_dropin_info = + manual_unit_file("$local_root/demo.service.d/00-local.conf"); + ok($manual_dropin_info, + 'manual_unit_file returns allowed drop-in descriptor'); + is(read_manual_unit_file($manual_dropin_info), + "[Service]\nRestart=always\n", + 'read_manual_unit_file reads system drop-in files'); + ($ok, $err) = write_manual_unit_file( + $manual_dropin_info, "[Service]\nRestart=on-failure\n"); + ok($ok, 'write_manual_unit_file writes system drop-in files'); + is(slurp_test_file("$local_root/demo.service.d/00-local.conf"), + "[Service]\nRestart=on-failure\n", + 'manual system drop-in write preserves exact file'); + unlink($main::unit_config_change_flag); + unlink($main::daemon_reload_time_flag); + ok(!needs_daemon_reload(), 'daemon reload is not needed initially'); + mark_units_changed(); + ok(needs_daemon_reload(), 'manual unit edits require daemon reload'); + like(action_links(), qr/restart\.cgi.*Reload/s, + 'header action links include reload when needed'); + mark_daemon_reloaded(); + ok(!needs_daemon_reload(), 'daemon reload clears manual edit reminder'); + { + local *main::get_user_details = sub { + return $_[0] eq 'alice' ? + { user => 'alice', uid => 1001, gid => 1001, + home => "$work/alice" } : undef; + }; + local %access = (view_user => 1, manual_user => 1, + mode => 1, users => 'alice'); + local %in = (unituser => 'alice'); + unlink(user_daemon_reload_flag_file('alice', 'changed')); + unlink(user_daemon_reload_flag_file('alice', 'reloaded')); + ok(!needs_user_daemon_reload('alice'), + 'user daemon reload is not needed initially'); + mark_user_units_changed('alice'); + ok(needs_user_daemon_reload('alice'), + 'manual user unit edits require user manager reload'); + like(action_links(), qr/restart_user\.cgi\?user=alice.*Reload User Manager/s, + 'header action links include user manager reload when needed'); + mark_user_daemon_reloaded('alice'); + ok(!needs_user_daemon_reload('alice'), + 'user manager reload clears user edit reminder'); + } +} + +{ + my $verify_root = "$work/verify-units"; + make_path($verify_root); + my @verify_commands; + my $verify_count = 0; + local *main::has_command = sub { + return $_[0] eq 'systemd-analyze' ? '/bin/systemd-analyze' : undef; + }; + local *main::tempname = sub { + return "$verify_root/verify-".($verify_count++); + }; + local *main::backquote_logged = sub { + my ($cmd) = @_; + push(@verify_commands, $cmd); + $? = 0; + return ""; + }; + my ($ok, $err) = verify_unit_data( + '/etc/systemd/system/demo.service', "[Unit]\nDescription=Demo\n", 0); + ok($ok, 'verify_unit_data accepts clean system unit data'); + like($verify_commands[-1], qr/verify .*demo\\.service/, + 'verify_unit_data preserves the real unit basename'); + unlike($verify_commands[-1], qr/--user/, + 'system unit verification does not use user mode'); + ok(!-e "$verify_root/verify-0/demo.service", + 'verify_unit_data removes successful temp files'); + + ($ok, $err) = verify_unit_data( + '/home/alice/.config/systemd/user/demo.service', + "[Unit]\nDescription=Demo\n", 1); + ok($ok, 'verify_unit_data accepts clean user unit data'); + like($verify_commands[-1], qr/--user verify/, + 'user unit verification uses user mode'); + + local *main::backquote_logged = sub { + my ($cmd) = @_; + push(@verify_commands, $cmd); + $? = 0; + return "bad.service:1: Unknown section 'UnitX'. Ignoring.\n"; + }; + ($ok, $err) = verify_unit_data( + '/etc/systemd/system/warn.service', "[UnitX]\nDescription=Bad\n", 0); + ok(!$ok, 'verify_unit_data rejects analyzer warnings'); + like($err, qr/Unknown section/, + 'verify_unit_data reports analyzer warning output'); + ok(!-e "$verify_root/verify-2/warn.service", + 'verify_unit_data removes warning temp files'); + + local *main::backquote_logged = sub { + my ($cmd) = @_; + push(@verify_commands, $cmd); + $? = 1; + return "\n"; + }; + ($ok, $err) = verify_unit_data( + '/etc/systemd/system/bad.service', "[Service]\nBroken\n", 0); + ok(!$ok, 'verify_unit_data rejects analyzer failures'); + like($err, qr/]*><bad unit>/, + 'verify_unit_data escapes analyzer failure output in tt tag'); + ok(!-e "$verify_root/verify-3/bad.service", + 'verify_unit_data removes failed temp files'); + + local *main::backquote_logged = sub { + my ($cmd) = @_; + push(@verify_commands, $cmd); + $? = 0; + return ""; + }; + ($ok, $err) = verify_dropin_data( + '/etc/systemd/system/drop.service', + "[Unit]\nDescription=Drop\n[Service]\nExecStart=/bin/true\n", + "[Service]\nRestart=always\n", 0); + ok($ok, 'verify_dropin_data accepts clean drop-in data'); + like($verify_commands[-1], qr/verify .*drop\\.service/, + 'verify_dropin_data verifies the base unit name'); + ok(!-e "$verify_root/verify-4/drop.service.d/override.conf", + 'verify_dropin_data removes temporary override files'); + + my $before_transient_verify = scalar(@verify_commands); + ($ok, $err) = verify_dropin_data( + '/run/systemd/transient/session-2.scope', + "[Unit]\nDescription=Transient\n[Scope]\n", + "[Unit]\nRequiresMountsFor=/root\n", 0, 'transient'); + ok($ok, 'verify_dropin_data skips transient unit drop-ins'); + is(scalar(@verify_commands), $before_transient_verify, + 'transient drop-in verification does not call systemd-analyze'); + + my @user_verify; + local *main::get_user_details = sub { + my ($user) = @_; + return $user eq 'alice' ? + { user => 'alice', uid => 1001, gid => 1001, + home => '/home/alice' } : undef; + }; + local *main::set_ownership_permissions = sub { return 1 }; + local *main::user_manager_command = sub { + my ($user, @cmd) = @_; + push(@user_verify, [ $user, @cmd ]); + return "as-user ".join(" ", @cmd); + }; + ($ok, $err) = verify_unit_data( + '/home/alice/.config/systemd/user/owned.service', + "[Unit]\nDescription=Owned\n", 1, 'alice'); + ok($ok, 'verify_unit_data accepts user-owned unit data'); + is($user_verify[-1]->[0], 'alice', + 'verify_unit_data verifies as the target user'); + like(join(" ", @{$user_verify[-1]}), qr/--user verify .*owned\\.service/, + 'user-owned verification uses the user manager command'); + ok(!-e "$verify_root/verify-5/owned.service", + 'user-owned verification removes temporary files'); + + ($ok, $err) = verify_dropin_data( + '/home/alice/.config/systemd/user/owned.service', + "[Unit]\nDescription=Owned\n[Service]\nExecStart=/bin/true\n", + "[Service]\nRestart=always\n", 1, undef, 'alice'); + ok($ok, 'verify_dropin_data accepts user-owned drop-in data'); + is($user_verify[-1]->[0], 'alice', + 'verify_dropin_data verifies as the target user'); +} + +{ + my $dropin_root = "$work/system-dropins"; + make_path($dropin_root); + local *main::get_system_dropin_roots = sub { + return ($dropin_root); + }; + local *main::system_dropin_file = sub { + my ($unit) = @_; + return "$dropin_root/$unit.d/override.conf"; + }; + my $template = dropin_template( + '/etc/systemd/system/demo.service.d/override.conf', + '/usr/lib/systemd/system/demo.service', + "[Unit]\nDescription=Demo\n"); + like($template, qr/^### Editing .*override\.conf/m, + 'dropin_template names the override file'); + like($template, qr/^# Description=Demo$/m, + 'dropin_template comments the base unit'); + is(dropin_effective_data($template."[Service]\nRestart=always\n"), + "### Editing /etc/systemd/system/demo.service.d/override.conf\n". + "### Anything between here and the comment below will become ". + "the new contents of the file\n\n\n\n", + 'dropin_effective_data discards commented base unit contents'); + + my ($ok, $out) = write_system_dropin_file('demo.service', $template); + ok($ok, 'write_system_dropin_file writes standard override files'); + is(slurp_test_file("$dropin_root/demo.service.d/override.conf"), + $template, 'system drop-in content is written'); + write_test_file("$dropin_root/demo.service.d/10-extra.conf", + "[Service]\nRestartSec=5s\n"); + make_path("$dropin_root/bad.service.d"); + symlink('/tmp/evil', "$dropin_root/bad.service.d/link.conf"); + my @system_dropins = list_system_dropin_override_files(); + is_deeply([ map { $_->{'unit'}.":".$_->{'name'} } @system_dropins ], + [ 'demo.service:10-extra.conf', + 'demo.service:override.conf' ], + 'system drop-in inventory lists safe config files'); + is(read_system_dropin_config_file( + "$dropin_root/demo.service.d/10-extra.conf"), + "[Service]\nRestartSec=5s\n", + 'system drop-in config reader opens exact safe file'); + ($ok, $out) = write_system_dropin_config_file( + "$dropin_root/demo.service.d/10-extra.conf", + "[Service]\nRestartSec=10s\n"); + ok($ok, 'system drop-in config writer updates exact safe file'); + is(slurp_test_file("$dropin_root/demo.service.d/10-extra.conf"), + "[Service]\nRestartSec=10s\n", + 'system drop-in config writer preserves non-standard filename'); + ok(dropin_exists(0, undef, 'demo.service'), + 'dropin_exists detects system override files'); + ($ok, $out) = delete_system_dropin_file('demo.service'); + ok($ok, 'delete_system_dropin_file removes standard override files'); + ok(!-e "$dropin_root/demo.service.d/override.conf", + 'system drop-in file is removed'); + ok(!dropin_exists(0, undef, 'demo.service'), + 'dropin_exists returns false after system override deletion'); + + make_path("$dropin_root/link.service.d"); + symlink('/tmp/evil', "$dropin_root/link.service.d/override.conf"); + ($ok, $out) = write_system_dropin_file('link.service', 'bad'); + ok(!$ok, 'write_system_dropin_file rejects symlink override files'); +} + +{ + my $home = "$work/alice-home"; + my $root = "$home/.config/systemd/user"; + make_path($root); + my @home_st = stat($home); + my $user_info = { + user => 'alice', + uid => $home_st[4], + gid => $home_st[5], + home => $home, + }; + local *main::get_user_details = sub { + my ($user) = @_; + return $user eq 'alice' ? $user_info : undef; + }; + local *main::eval_as_unix_user = sub { + my ($user, $code) = @_; + return $code->(); + }; + ok(user_root_safe('alice'), 'safe user unit root accepts real directories'); + my ($dirs_ok, $dirs_out) = check_user_unit_dirs('alice'); + ok($dirs_ok, 'check_user_unit_dirs accepts user-owned unit tree'); + my $real_uid = $user_info->{'uid'}; + $user_info->{'uid'} = $real_uid + 1; + ($dirs_ok, $dirs_out) = check_user_unit_dirs('alice'); + ok(!$dirs_ok, 'check_user_unit_dirs rejects wrongly-owned unit tree'); + is($dirs_out, 'bad user unit dir', 'wrongly-owned dir error'); + $user_info->{'uid'} = $real_uid; + symlink('/tmp', "$home/.config/systemd/user/link-test"); + ok(!user_unit_file_safe('alice', "$root/link-test", 1), + 'user unit file safety rejects symlink files'); + unlink("$home/.config/systemd/user/link-test"); + ok(user_unit_file_safe('alice', "$root/demo.service", 0), + 'user unit file safety accepts direct unit child'); + ok(!user_unit_file_safe('alice', "$root/../demo.service", 0), + 'user unit file safety rejects traversal'); + + my ($ok, $out) = write_user_unit_file( + 'alice', "$root/demo.service", "[Unit]\nDescription=Alice\n"); + ok($ok, 'write_user_unit_file writes through dropped-user helper'); + is(slurp_test_file("$root/demo.service"), "[Unit]\nDescription=Alice\n", + 'user unit file content is written'); + is(read_user_unit_file('alice', "$root/demo.service"), + "[Unit]\nDescription=Alice\n", + 'read_user_unit_file reads through dropped-user helper'); + is(user_file_description('alice', "$root/demo.service"), 'Alice', + 'user unit description is parsed'); + + my $dropin = user_dropin_file('alice', 'demo.service'); + ($ok, $out) = write_user_dropin_file( + 'alice', 'demo.service', "[Service]\nRestart=always\n"); + ok($ok, 'write_user_dropin_file writes through dropped-user helper'); + is(slurp_test_file($dropin), "[Service]\nRestart=always\n", + 'user drop-in file content is written'); + write_test_file("$root/demo.service.d/20-local.conf", + "[Service]\nEnvironment=DEMO=1\n"); + my @user_dropins = list_user_dropin_override_files('alice'); + is_deeply([ map { $_->{'user'}.":".$_->{'unit'}.":".$_->{'name'} } + @user_dropins ], + [ 'alice:demo.service:20-local.conf', + 'alice:demo.service:override.conf' ], + 'user drop-in inventory lists safe config files'); + is(read_user_dropin_config_file('alice', + "$root/demo.service.d/20-local.conf"), + "[Service]\nEnvironment=DEMO=1\n", + 'user drop-in config reader opens exact safe file'); + ($ok, $out) = write_user_dropin_config_file( + 'alice', "$root/demo.service.d/20-local.conf", + "[Service]\nEnvironment=DEMO=2\n"); + ok($ok, 'user drop-in config writer updates exact safe file'); + is(slurp_test_file("$root/demo.service.d/20-local.conf"), + "[Service]\nEnvironment=DEMO=2\n", + 'user drop-in config writer preserves non-standard filename'); + ok(dropin_exists(1, 'alice', 'demo.service'), + 'dropin_exists detects user override files'); + is(read_user_dropin_file('alice', 'demo.service'), + "[Service]\nRestart=always\n", + 'read_user_dropin_file reads through dropped-user helper'); + is(user_file_description('alice', "$root/demo.service", 'demo.service'), + 'Alice', + 'user unit description ignores drop-ins without descriptions'); + ($ok, $out) = write_user_dropin_file( + 'alice', 'demo.service', + "[Unit]\nDescription=Alice Override\n[Service]\nRestart=always\n"); + ok($ok, 'write_user_dropin_file updates user override files'); + is(user_file_description('alice', "$root/demo.service", 'demo.service'), + 'Alice Override', + 'user unit description honors drop-in descriptions'); + my @dropin_local = list_user_units('alice'); + is($dropin_local[0]->{'desc'}, 'Alice Override', + 'offline user unit listing uses drop-in description'); + ($ok, $out) = delete_user_dropin_file('alice', 'demo.service'); + ok($ok, 'delete_user_dropin_file removes through dropped-user helper'); + ok(!-e $dropin, 'user drop-in file is removed'); + ok(!dropin_exists(1, 'alice', 'demo.service'), + 'dropin_exists returns false after user override deletion'); + + make_path("$root/link.service.d"); + symlink('/tmp/evil', "$root/link.service.d/override.conf"); + ($ok, $out) = write_user_dropin_file('alice', 'link.service', 'bad'); + ok(!$ok, 'write_user_dropin_file rejects symlink override files'); + + make_path("$root/default.target.wants"); + symlink("$root/demo.service", "$root/default.target.wants/demo.service"); + ok(user_file_enabled('alice', 'demo.service'), + 'user unit enabled state is detected from wants symlink'); + + my @local = list_user_units('alice'); + is(@local, 1, 'offline user unit listing includes local file'); + is($local[0]->{'name'}, 'demo.service', 'offline user unit name'); + is($local[0]->{'desc'}, 'Alice', 'offline user unit description'); + is($local[0]->{'boot'}, 1, 'offline user unit boot state'); + is($local[0]->{'unitstate'}, 'enabled', + 'offline user unit file state is inferred'); + is($local[0]->{'runtime'}, undef, + 'offline user unit runtime state is unknown'); + is($local[0]->{'substate'}, undef, + 'offline user unit sub-state is unknown'); + + ok(delete_user_unit_file('alice', "$root/demo.service"), + 'delete_user_unit_file removes safe direct unit'); + ok(!-e "$root/demo.service", 'user unit file was deleted'); +} + +{ + local $config{'visible_tabs'} = 'service,timer'; + is_deeply([ list_all_user_units() ], [ ], + 'list_all_user_units honors hidden user tab'); +} + +{ + my $home = "$work/bob-home"; + my $root = "$home/.config/systemd/user"; + make_path($home); + my @home_st = stat($home); + my $user_info = { + user => 'bob', + uid => $home_st[4], + gid => $home_st[5], + home => $home, + }; + local *main::get_user_details = sub { + my ($user) = @_; + return $user eq 'bob' ? $user_info : undef; + }; + local *main::eval_as_unix_user = sub { + my ($user, $code) = @_; + return $code->(); + }; + local *main::reload_user_manager = sub { return (1, '') }; + local *main::has_command = sub { return }; + is(make_user_root('bob'), $root, + 'make_user_root creates the user unit directory'); + ok(-d $root, 'user unit directory exists'); + my $bob_service = render_unit({ + type => 'service', + description => 'Bob', + service => { + start => '/bin/true', + }, + options => { + wantedby => 'default.target', + }, + }); + my ($ok, $out) = create_user_unit( + 'bob', 'bob.service', $bob_service); + ok($ok, 'create_user_unit writes service with mocked user manager'); + ok(-f "$root/bob.service", 'user service file was created'); + like(slurp_test_file("$root/bob.service"), qr/^WantedBy=default\.target$/m, + 'user service install target written'); + my $bob_timer = render_unit({ + type => 'timer', + description => 'Bob timer', + body => 'OnCalendar=daily', + options => { + wantedby => 'timers.target', + }, + }); + ($ok, $out) = create_user_unit( + 'bob', 'bob.timer', $bob_timer); + ok($ok, 'create_user_unit writes timer with mocked user manager'); + ok(-f "$root/bob.timer", 'user timer file was created'); + ($ok, $out) = delete_user_unit('bob', 'bob'); + ok(!$ok, 'delete_user_unit rejects bare service name'); + ok(-e "$root/bob.service", 'delete_user_unit leaves typed file after bare rejection'); + ($ok, $out) = delete_user_unit('bob', 'bob.service'); + ok($ok, 'delete_user_unit accepts typed service name'); + ok(!-e "$root/bob.service", 'delete_user_unit removed service file'); + ($ok, $out) = delete_user_unit('bob', 'bob.service'); + ok(!$ok, 'delete_user_unit rejects already-missing unit'); + is($out, $text{'systemd_egone'}, + 'delete_user_unit reports stale missing unit'); +} + +{ + my @args; + local *main::get_user_details = sub { + return { user => 'carol', uid => 1003, gid => 1003, + home => '/home/carol' }; + }; + local *main::command_as_user = sub { + my ($user, $mode, $cmd) = @_; + push(@args, [ $user, $mode, $cmd ]); + return "as-user $cmd"; + }; + my $cmd = user_systemctl_command( + 'carol', 'start', 'bad;touch.service'); + is($args[0]->[0], 'carol', 'user command runs as selected Unix user'); + like($cmd, qr/HOME=\\\/home\\\/carol/, 'user command sets HOME'); + like($cmd, qr/XDG_RUNTIME_DIR=\\\/run\\\/user\\\/1003/, + 'user command sets runtime directory'); + like($cmd, qr/DBUS_SESSION_BUS_ADDRESS=unix\\:path\\=\\\/run\\\/user\\\/1003\\\/bus/, + 'user command sets user bus address'); + like($cmd, qr/--user start bad\\;touch\\.service/, + 'systemctl --user command quotes hostile unit names'); +} + +{ + my @cmds; + local *main::user_systemctl_command = sub { + my ($user, @args) = @_; + push(@cmds, [ $user, @args ]); + return "user-systemctl ".join(" ", @args); + }; + local *main::backquote_logged = sub { + $? = 0; + return 'ran'; + }; + my ($ok, $out) = run_user_systemctl('alice', 'status', + 'demo.service'); + ok($ok, 'run_user_systemctl reports command success'); + is($out, 'ran', 'run_user_systemctl returns command output'); + is_deeply($cmds[-1], [ 'alice', 'status', 'demo.service' ], + 'run_user_systemctl builds requested arguments'); + ($ok) = reload_user_manager('alice'); + ok($ok, 'reload_user_manager runs daemon-reload'); + is_deeply($cmds[-1], [ 'alice', 'daemon-reload' ], + 'reload_user_manager reloads the user manager'); +} + +{ + my @run; + local *main::run_user_systemctl = sub { + push(@run, [ @_ ]); + return (1, 'ok'); + }; + local *main::check_user_unit_dirs = sub { + return (1, undef); + }; + my ($ok) = start_user_unit('alice', 'demo.service'); + ok($ok, 'start_user_unit delegates to systemctl --user'); + is_deeply($run[-1], [ 'alice', 'start', 'demo.service' ], + 'start_user_unit arguments'); + ($ok) = stop_user_unit('alice', 'demo.service'); + is_deeply($run[-1], [ 'alice', 'stop', 'demo.service' ], + 'stop_user_unit arguments'); + ($ok) = restart_user_unit('alice', 'demo.service'); + is_deeply($run[-1], [ 'alice', 'restart', 'demo.service' ], + 'restart_user_unit arguments'); + ($ok) = status_user_unit('alice', 'demo.service'); + is_deeply($run[-1], [ 'alice', '--full', '--no-pager', + 'status', 'demo.service' ], + 'status_user_unit arguments'); + ($ok) = properties_user_unit('alice', 'demo.service'); + is_deeply($run[-1], [ 'alice', '--full', '--no-pager', + 'show', 'demo.service' ], + 'properties_user_unit arguments'); + ($ok) = dependencies_user_unit('alice', 'demo.service'); + is_deeply($run[-1], [ 'alice', '--full', '--no-pager', + 'list-dependencies', 'demo.service' ], + 'dependencies_user_unit arguments'); + ($ok) = enable_user_unit('alice', 'demo.service'); + is_deeply($run[-1], [ 'alice', 'enable', 'demo.service' ], + 'enable_user_unit arguments'); + ($ok) = disable_user_unit('alice', 'demo.service'); + is_deeply($run[-1], [ 'alice', 'disable', 'demo.service' ], + 'disable_user_unit arguments'); + ($ok) = mask_user_unit('alice', 'demo.service'); + is_deeply($run[-1], [ 'alice', 'mask', 'demo.service' ], + 'mask_user_unit arguments'); + ($ok) = unmask_user_unit('alice', 'demo.service'); + is_deeply($run[-1], [ 'alice', 'unmask', 'demo.service' ], + 'unmask_user_unit arguments'); + my $before_invalid = scalar(@run); + ($ok) = start_user_unit('alice', 'bad;touch.service'); + ok(!$ok, 'start_user_unit rejects invalid unit names'); + is(scalar(@run), $before_invalid, + 'invalid user unit name builds no systemctl command'); +} + +{ + my @cmds; + local *main::has_command = sub { + return $_[0] eq 'journalctl' ? '/bin/journalctl' : undef; + }; + local *main::get_user_details = sub { + return { user => 'alice', uid => 1003, gid => 1003, + home => '/home/alice' }; + }; + local *main::backquote_logged = sub { + my ($cmd) = @_; + push(@cmds, $cmd); + $? = 0; + return 'user logs'; + }; + local $config{'logs_lines'} = 77; + my ($ok, $out) = logs_user_unit('alice', 'demo.service'); + ok($ok, 'logs_user_unit reports success'); + is($out, 'user logs', 'logs_user_unit returns output'); + like($cmds[-1], + qr/\\\/bin\\\/journalctl --no-pager _UID=1003 _SYSTEMD_USER_UNIT=demo\\\.service --lines 77/, + 'logs_user_unit filters journal by owner and unit'); + + local $config{'logs_current_boot'} = 1; + logs_user_unit('alice', 'demo.service'); + like($cmds[-1], qr/--boot/, + 'logs_user_unit adds boot filter when configured'); +} + +{ + my @cmds; + local *main::has_command = sub { + return $_[0] eq 'loginctl' ? '/bin/loginctl' : + $_[0] eq 'systemctl' ? '/bin/systemctl' : undef; + }; + local *main::get_user_details = sub { + return { user => 'alice', uid => 1001, gid => 1001, + home => '/home/alice' }; + }; + local *main::backquote_logged = sub { + push(@cmds, $_[0]); + $? = 0; + return 'ok'; + }; + my ($ok) = set_user_linger('alice', 1); + ok($ok, 'set_user_linger reports success'); + like($cmds[-1], qr/\\\/bin\\\/loginctl enable-linger alice/, + 'linger enable command is built'); + ($ok) = start_user_manager('alice'); + ok($ok, 'start_user_manager reports success'); + like($cmds[-1], qr/\\\/bin\\\/systemctl start user\\\@1001\\.service/, + 'user manager unit is started by UID'); +} + +{ + local *main::get_user_details = sub { + return { user => 'unit-test-linger-user', uid => 2001, + gid => 2001, home => '/home/unit-test-linger-user' }; + }; + local *main::has_command = sub { + return $_[0] eq 'loginctl' ? '/bin/loginctl' : undef; + }; + local *main::backquote_command = sub { + return "Linger=yes\n"; + }; + ok(user_linger_enabled('unit-test-linger-user'), + 'linger state falls back to loginctl'); +} + +{ + my $cat_data = <<'EOF'; +# /usr/lib/systemd/system/demo.socket +[Socket] +ListenStream=80 +ListenStream=443 + +[Install] +WantedBy=sockets.target +EOF + local *main::open_execute_command = sub { + my ($fh, $cmd) = @_; + open($fh, '<', \$cat_data) or die "open scalar: $!"; + return 1; + }; + my $conf = cat_unit('demo.socket'); + is($conf->[0]->{'file'}, '/usr/lib/systemd/system/demo.socket', + 'cat_unit captures source file'); + is_deeply($conf->[0]->{'sections'}->{'Socket'}->{'ListenStream'}, + [ '80', '443' ], + 'cat_unit captures repeated keys'); + my $filtered = cat_unit('demo.socket', 'Listen'); + ok($filtered->[0]->{'sections'}->{'Socket'}->{'ListenStream'}, + 'cat_unit filter keeps matching keys'); + ok(!$filtered->[0]->{'sections'}->{'Install'}, + 'cat_unit filter removes non-matching sections'); +} + +{ + my $override_dir = "$work/override/demo.service.d"; + my @reloads; + make_path("$work/override"); + local *main::system_logged = sub { + push(@reloads, $_[0]); + return 0; + }; + edit_unit('demo.service', + { Service => { Environment => [ 'A=1', 'B=2' ] } }, + 'override.conf', $override_dir); + my $override = slurp_test_file("$override_dir/override.conf"); + like($override, qr/^\[Service\]\nEnvironment=A=1\nEnvironment=B=2/m, + 'edit_unit writes override settings'); + edit_unit('demo.service', + { Service => { Environment => [ 'C=3' ] } }, + 'override.conf', $override_dir); + $override = slurp_test_file("$override_dir/override.conf"); + unlike($override, qr/Environment=A=1/, 'edit_unit replaces old key values'); + like($override, qr/Environment=C=3/, 'edit_unit keeps new key values'); + is($reloads[-1], 'systemctl daemon-reload', + 'edit_unit reloads daemon after writing override'); +} + +do "$bindir/../log_parser.pl"; +die $@ if $@; +like(parse_webmin_log('root', '', 'create', 'systemd', + '.service', {}), + qr/<img src=x onerror=1>/, + 'system unit log parser escapes unit names'); +like(parse_webmin_log('root', '', 'override', 'systemd', + 'demo.service', {}), + qr/drop-in override.*demo\.service|demo\.service.*drop-in override/i, + 'override system unit log is parsed'); +like(parse_webmin_log('root', '', 'deleteoverride', 'systemd', + 'demo.service', {}), + qr/drop-in override.*demo\.service|demo\.service.*drop-in override/i, + 'drop-in delete system unit log is parsed'); +like(parse_webmin_log('root', '', 'deps', 'systemd', + 'demo.service', {}), + qr/Listed dependencies.*demo\.service|demo\.service.*dependencies/i, + 'dependency system unit log is parsed'); +like(parse_webmin_log('root', '', 'props', 'systemd', + 'demo.service', {}), + qr/Fetched properties.*demo\.service|demo\.service.*properties/i, + 'properties system unit log is parsed'); +like(parse_webmin_log('root', '', 'massstart', 'systemd-user', + "a.service b.timer", + { user => '' }), + qr/<owner>/, + 'user unit log parser escapes owner names'); +like(parse_webmin_log('root', '', 'override', 'systemd-user', + "a.service", + { user => '' }), + qr/<owner>.*a\.service|a\.service.*<owner>/s, + 'override user unit log is parsed'); +like(parse_webmin_log('root', '', 'deleteoverride', 'systemd-user', + "a.service", + { user => '' }), + qr/<owner>.*a\.service|a\.service.*<owner>/s, + 'drop-in delete user unit log is parsed'); +like(parse_webmin_log('root', '', 'deps', 'systemd-user', + "a.service", + { user => '' }), + qr/<owner>.*a\.service|a\.service.*<owner>/s, + 'dependency user unit log is parsed'); +like(parse_webmin_log('root', '', 'props', 'systemd-user', + "a.service", + { user => '' }), + qr/<owner>.*a\.service|a\.service.*<owner>/s, + 'properties user unit log is parsed'); +like(parse_webmin_log('root', '', 'massdelete', 'systemd-user', + "a.service b.timer", + { user => '' }), + qr/<owner>.*a\.service|a\.service.*<owner>/s, + 'mass delete user unit log is parsed'); +like(parse_webmin_log('root', '', 'linger', 'systemd-user', '', + { user => 'alice', enabled => 1 }), + qr/Alice|alice|Yes/, + 'linger log parser returns translated output'); +like(parse_webmin_log('root', '', 'manual', 'systemd', + '/etc/systemd/system/demo.service', {}), + qr/demo\.service/, + 'manual system unit file log is parsed'); +like(parse_webmin_log('root', '', 'manual', 'systemd-user', + '/home/alice/.config/systemd/user/demo.service', + { user => 'alice' }), + qr/alice.*demo\.service|demo\.service.*alice/, + 'manual user unit file log is parsed'); +like(parse_webmin_log('root', '', 'reload', 'systemd', '', {}), + qr/Reloaded|systemd/i, + 'daemon reload log is parsed'); +like(parse_webmin_log('root', '', 'reload', 'systemd-user', 'alice', + { user => 'alice' }), + qr/alice/, + 'user manager reload log is parsed'); + +do "$bindir/../backup_config.pl"; +die $@ if $@; +{ + local %access = ( backup => 1, mode => 0 ); + local *main::list_units = sub { + return ( + { name => 'demo.service', + file => '/etc/systemd/system/demo.service' }, + { name => 'vendor.service', + file => '/usr/lib/systemd/system/vendor.service' }, + ); + }; + local *main::list_all_user_units = sub { + return ( { name => 'demo.service', + file => '/home/alice/.config/systemd/user/demo.service', + user => 'alice' } ); + }; + local *main::dropin_exists = sub { + my ($user_scope, $user, $unit) = @_; + return !$user_scope && + ($unit eq 'demo.service' || $unit eq 'vendor.service') ? 1 : + $user_scope && $user eq 'alice' && + $unit eq 'demo.service' ? 1 : 0; + }; + local *main::system_dropin_file = sub { + my ($unit) = @_; + return "/etc/systemd/system/$unit.d/override.conf"; + }; + local *main::user_dropin_file = sub { + my ($user, $unit) = @_; + return "/home/$user/.config/systemd/user/$unit.d/override.conf"; + }; + is_deeply([ backup_config_files() ], + [ '/etc/systemd/system/demo.service', + '/etc/systemd/system/demo.service.d/override.conf', + '/etc/systemd/system/vendor.service.d/override.conf', + '/home/alice/.config/systemd/user/demo.service', + '/home/alice/.config/systemd/user/demo.service.d/override.conf' ], + 'backup_config_files includes local system, user and drop-in files'); + local %access = ( backup => 1, mode => 1, users => 'bob' ); + is_deeply([ backup_config_files() ], + [ '/etc/systemd/system/demo.service', + '/etc/systemd/system/demo.service.d/override.conf', + '/etc/systemd/system/vendor.service.d/override.conf' ], + 'backup_config_files filters user units and drop-ins by owner ACL'); + local %access = ( backup => 0, mode => 0 ); + is_deeply([ backup_config_files() ], [], + 'backup_config_files honors backup ACL denial'); + my $reloaded = 0; + local *main::reload_manager = sub { $reloaded++ }; + post_restore(); + is($reloaded, 1, 'post_restore reloads systemd'); + is(pre_backup(), undef, 'pre_backup has no side effects'); + is(post_backup(), undef, 'post_backup has no side effects'); + is(pre_restore(), undef, 'pre_restore has no side effects'); +} + +do "$bindir/../acl_security.pl"; +die $@ if $@; +{ + local %in = ( mode => 1, userscan => 'alice bob', + view => 1, view_user => 1, create_user => 1, + logs => 0, logs_user => 1 ); + my %acl; + acl_security_save(\%acl); + is($acl{'mode'}, 1, 'ACL save stores user restriction mode'); + is($acl{'users'}, 'alice bob', + 'ACL save stores user restriction list'); + is($acl{'view'}, 1, 'ACL save stores system view permission'); + is($acl{'create_user'}, 1, + 'ACL save stores user unit create permission'); + is($acl{'logs'}, 0, 'ACL save denies missing granular permission'); + is($acl{'logs_user'}, 1, + 'ACL save stores user-scope granular permission'); + ok(!exists($acl{'units'}), 'ACL save omits removed units ACL key'); + local %in = ( mode => 99, userscan => 'alice', view_user => 1 ); + acl_security_save(\%acl); + is($acl{'mode'}, 0, 'ACL save rejects invalid user mode'); +} + +do "$bindir/../install_check.pl"; +die $@ if $@; +{ + local *main::has_command = sub { + return $_[0] eq 'systemctl' ? '/bin/systemctl' : undef; + }; + is(is_installed(0), 1, 'install check detects systemctl'); + is(is_installed(1), 2, 'configured install check reports visible module'); +} +{ + local *main::has_command = sub { return }; + is(is_installed(0), 0, 'install check rejects missing systemctl'); +} + +do "$bindir/../syslog_logs.pl"; +die $@ if $@; +{ + local *main::has_command = sub { + return $_[0] eq 'journalctl' ? '/bin/journalctl' : undef; + }; + my @logs = syslog_getlogs(); + is($logs[0]->{'cmd'}, 'journalctl -n 1000', + 'syslog log source exposes journalctl command'); +} + +my $index_source = slurp_test_file("$bindir/../index.cgi"); +my $mass_source = slurp_test_file("$bindir/../mass_units.cgi"); +my $save_source = slurp_test_file("$bindir/../save_unit.cgi"); +my $edit_source = slurp_test_file("$bindir/../edit_unit.cgi"); +my $edit_manual_source = slurp_test_file("$bindir/../edit_manual.cgi"); +my $dropins_source = slurp_test_file("$bindir/../dropins.cgi"); +my $save_manual_source = slurp_test_file("$bindir/../save_manual.cgi"); +my $restart_source = slurp_test_file("$bindir/../restart.cgi"); +my $restart_user_source = slurp_test_file("$bindir/../restart_user.cgi"); +my $acl_source = slurp_test_file("$bindir/../acl_security.pl"); +my $defaultacl_source = slurp_test_file("$bindir/../defaultacl"); +my $safeacl_source = slurp_test_file("$bindir/../safeacl"); +my $config_source = slurp_test_file("$bindir/../config.info"); +my $config_info_source = slurp_test_file("$bindir/../config_info.pl"); +my $lib_source = slurp_test_file("$bindir/../systemd-lib.pl"); +my $type_help_source = slurp_test_file("$bindir/../help/systemd_type.html"); +my $type_user_help_source = + slurp_test_file("$bindir/../help/systemd_type_user.html"); +my $path_help_source = + slurp_test_file("$bindir/../help/systemd_pathexists.html"); +my $socket_fifo_help_source = + slurp_test_file("$bindir/../help/systemd_socketlistenfifo.html"); +my $socket_user_help_source = + slurp_test_file("$bindir/../help/systemd_socketuser.html"); +my $workdir_help_source = + slurp_test_file("$bindir/../help/systemd_workdir.html"); +my $envfile_help_source = + slurp_test_file("$bindir/../help/systemd_envfile.html"); +my $limitnofile_help_source = + slurp_test_file("$bindir/../help/systemd_limitnofile.html"); +my $logstd_help_source = + slurp_test_file("$bindir/../help/systemd_logstd.html"); +my $logerr_help_source = + slurp_test_file("$bindir/../help/systemd_logerr.html"); +my $socketaccept_help_source = + slurp_test_file("$bindir/../help/systemd_socketaccept.html"); +my $socketservice_help_source = + slurp_test_file("$bindir/../help/systemd_socketservice.html"); +my $timerpersistent_help_source = + slurp_test_file("$bindir/../help/systemd_timerpersistent.html"); +my $conf_help_source = + slurp_test_file("$bindir/../help/systemd_conf.html"); +my $file_help_source = + slurp_test_file("$bindir/../help/systemd_file.html"); +my $readwritepaths_help_source = + slurp_test_file("$bindir/../help/systemd_readwritepaths.html"); +my $unitconf_help_source = + slurp_test_file("$bindir/../help/systemd_unitconf.html"); +my $slice_help_source = + slurp_test_file("$bindir/../help/systemd_slicecpuweight.html"); +foreach my $file (sort glob("$bindir/../help/systemd_*.html")) { + my ($key) = $file =~ m{/([^/]+)\.html$}; + next if (!$key || !defined($text{$key})); + my $source = slurp_test_file($file); + my ($header) = $source =~ m{^
(.*?)
}; + is($header, $text{$key}, "$key help title matches field label"); +} +like($config_source, qr/^logs_lines=/m, + 'module config exposes journal line count'); +like($config_source, qr/^logs_current_boot=/m, + 'module config exposes journal boot filtering'); +like($config_source, qr/^visible_tabs=/m, + 'module config exposes visible tab selection'); +like($config_source, qr/^show_runtime_units=/m, + 'module config exposes generated and transient unit visibility'); +like($config_source, qr/^default_create_scope=/m, + 'module config exposes default create scope'); +like($config_source, qr/^manual_vendor_units=/m, + 'module config exposes vendor-file manual editor visibility'); +like($config_source, qr/^default_linger=/m, + 'module config exposes default linger choice'); +like($config_source, qr/^show_dropin_inventory=/m, + 'module config exposes drop-in inventory visibility'); +like($config_source, qr/^create_return_index=/m, + 'module config exposes post-create redirect destination'); +like($config_info_source, qr/sub parse_visible_tabs\b/, + 'module config rejects saving with every tab hidden'); +like($defaultacl_source, qr/^view_user=1/m, + 'default ACL grants user unit viewing'); +like($defaultacl_source, qr/^manual_user=1/m, + 'default ACL grants user unit manual editing'); +like($defaultacl_source, qr/^start_user=1/m, + 'default ACL grants user unit runtime control'); +like($safeacl_source, qr/^mode=3/m, + 'safe ACL scopes user units to the current Webmin user'); +like($safeacl_source, qr/^view=0/m, + 'safe ACL denies system unit viewing'); +like($safeacl_source, qr/^view_user=1/m, + 'safe ACL allows user unit viewing'); +like($safeacl_source, qr/^create=0/m, + 'safe ACL denies system unit creation'); +like($safeacl_source, qr/^create_user=1/m, + 'safe ACL allows user unit creation'); +like($safeacl_source, qr/^mask_user=0/m, + 'safe ACL denies user-unit masking'); +like($acl_source, qr/acl_section_users/, + 'ACL editor exposes user owner restriction section'); +like($acl_source, qr/systemd_acl_keys/, + 'ACL editor saves all granular systemd permissions'); +like($lib_source, qr/sub systemd_acl_user_allowed\b/, + 'library contains user owner ACL helper'); +like($lib_source, qr/sub systemd_acl_default_user\b/, + 'library contains default user owner helper'); +like($lib_source, qr/sub systemd_can_runtime\b/, + 'library contains runtime ACL helper'); +like($lib_source, qr/sub systemd_can_manual\b/, + 'library contains manual file ACL helper'); +like($index_source, qr/sub index_tabs\b/, + 'index contains tab builder helper'); +like($index_source, qr/sub index_tab_groups\b/, + 'index defines grouped tabs for related unit types'); +like($index_source, qr/next if \(!systemd_can_view_system\(\\%access\)\)/, + 'index shows system tabs only when system scope is allowed'); +unlike($index_source, qr/next if \(!\@\$units\)/, + 'index keeps visible tabs even when they have no units'); +like($index_source, qr/tab_visible\('user'\).*?systemd_can_view_user_scope/s, + 'index keeps the user tab when user scope is allowed'); +like($index_source, qr/sub index_empty_message\b/, + 'index contains tab empty-state helper'); +like($index_source, qr/!\@\{\$tab->\{'units'\}\}.*?index_empty_message/s, + 'index shows an empty state instead of an empty table'); +like($index_source, qr/ui_tag\('p',\s*index_empty_message\(\$tab\)\)/, + 'index renders empty-state messages as paragraphs'); +like($index_source, qr/sub index_create_link\b/, + 'index shares create-link logic with empty states'); +like($index_source, qr/\$formno\+\+ if \(print_index_tab\(\$tab, \$formno\)\)/, + 'index form counter skips empty-state tabs'); +like($index_source, qr/!\@\{\$tab->\{'units'\}\}.*?return 0;/s, + 'index empty-state tabs report no mass-action form'); +like($index_source, qr/storage.*mount.*automount.*swap/s, + 'index groups storage unit types together'); +like($index_source, qr/resources.*slice.*scope/s, + 'index groups resource-control unit types together'); +like($index_source, qr/device.*inspect_only/s, + 'index treats device units as inspection-oriented'); +like($index_source, qr/sub linger_toggle_link\b/, + 'index contains linger toggle helper'); +like($index_source, qr/sub index_unit_state_column\b/, + 'index contains unit-file state formatter'); +unlike($index_source, qr/sub index_boot_column\b/, + 'index no longer reduces unit state to boot yes-no values'); +like($index_source, qr/ui_form_grouped_buttons/, + 'index mass actions use grouped button API'); +unlike($index_source, qr/index_depsnow|index_propsnow/, + 'index mass actions omit deeper inspect buttons'); +unlike($index_source, qr/addboot_start|delboot_stop/, + 'index omits combined start-enable mass actions'); +like($index_source, qr/sub print_index_tools\b/, + 'index contains advanced tools block'); +like($index_source, qr/dropins\.cgi.*?show_dropin_inventory/s, + 'index links configurable drop-in inventory action'); +like($index_source, qr/action_links\(\)/, + 'index header includes conditional daemon reload action'); +like($index_source, qr/systemd_can_enter_module/, + 'index gates module entry through granular ACLs'); +like($index_source, qr/systemd_acl_user_allowed/, + 'index filters user units by ACL owner rules'); +like($index_source, + qr/my \$can_mask = \$user_tab \? 0 :\s*systemd_can_mask/s, + 'index hides mask actions on user-unit tabs'); +like($index_source, qr/my \$can_delete = \$user_tab \?/, + 'index shows delete actions only on user-unit tabs'); +like($index_source, qr/\[ "delete", \$text\{'index_delete'\} \]/, + 'index includes a mass delete button'); +like($index_source, qr/ui_form_grouped_buttons\(\[ \[ \@action_groups \],\s*\[ \\\@delete_buttons \] \]\)/s, + 'index isolates mass delete buttons on the far side'); +like($mass_source, qr/sub mass_units\b/, + 'mass action page contains selection parser helper'); +like($mass_source, qr/sub mass_log\b/, + 'mass action page contains grouped logging helper'); +unlike($mass_source, qr/addboot_start|delboot_stop/, + 'mass action page omits combined start-enable handling'); +like($mass_source, qr/sub print_action_result\b/, + 'mass action page contains result details helper'); +like($mass_source, qr/returndropin/, + 'mass action return links preserve override edit context'); +like($mass_source, qr/returndropfile/, + 'mass action return links preserve exact drop-in file context'); +like($mass_source, qr/returnindex/, + 'mass action page can return transient actions to the owning tab'); +like($mass_source, qr/sub print_action_start\b.*data-first-print/s, + 'mass action first progress line uses progressive print marker'); +like($mass_source, qr/if \(\$printed_action_result\) \{\s*print ui_tag\('div', '', \{ 'class' => 'systemd-action-break'/s, + 'mass action result spacer is emitted only before the next action'); +like($mass_source, qr/'style' => 'height: 1em;'/, + 'mass action result spacer is sized for readable separation'); +like($mass_source, qr/print \$title, "\\n";/, + 'mass action result without details avoids a trailing break'); +unlike($mass_source, qr/ui_details\(\{.*?print ui_br\(\), "\\n";/s, + 'mass action result with folded details avoids a trailing break'); +like($mass_source, qr/'class'\s*=>\s*'inline inlined'/, + 'mass action results use inline details styling'); +like($mass_source, qr/ui_tag\('pre'.*?'style'\s*=>\s*'margin-left: 10px;'/s, + 'mass action output uses theme pre styling like GRUB'); +unlike($mass_source, qr/data-x-br/, + 'mass action results do not add extra spacer breaks'); +like($mass_source, qr/systemd_can_runtime/, + 'mass action page checks runtime ACLs'); +like($mass_source, qr/systemd_can_boot/, + 'mass action page checks boot ACLs'); +like($mass_source, qr/systemd_can_delete/, + 'mass action page checks delete ACLs'); +like($mass_source, qr/delete_user_unit/, + 'mass action page can delete user units'); +like($save_source, qr/write_user_unit_file/, + 'save page uses safe user-unit writer'); +like($save_source, qr/unit_file_editable/, + 'save page rejects direct writes to runtime-managed unit files'); +like($save_source, qr/verify_unit_data/, + 'save page verifies raw unit edits before writing'); +like($save_source, qr/dropin_template/, + 'save page can create systemctl-edit style drop-in templates'); +like($save_source, qr/verify_dropin_data/, + 'save page verifies edited drop-in overrides'); +like($save_source, qr/dropfile/, + 'save page preserves exact drop-in file edits'); +like($save_source, qr/write_system_dropin_config_file/, + 'save page can update non-standard system drop-ins'); +like($save_source, qr/write_user_dropin_config_file/, + 'save page can update non-standard user drop-ins'); +like($save_source, qr/delete_user_dropin_file/, + 'save page can delete user drop-in overrides'); +like($save_source, qr/delete_system_dropin_file/, + 'save page can delete system drop-in overrides'); +like($save_source, + qr/my \(\$ok, \$out\) = delete_system_unit\(\$in\{'name'\}\);\s*\$ok \|\| error\(\$out\);/s, + 'save page reports failed system unit deletes'); +like($save_source, qr/stock_unit/, + 'save page can return from override edits without saving'); +like($save_source, qr/returndropin/, + 'save page preserves override context through runtime actions'); +like($save_source, qr/returnindex/, + 'save page avoids returning stopped runtime-managed units to edit'); +like($save_source, qr/if \(!\$in\{'new'\} &&\s*\(\$in\{'start'\}/, + 'save page treats runtime actions as edit-only actions'); +like($save_source, qr/\$in\{'restart'\} = \$in\{'restart_policy'\}/, + 'save page maps create-form restart policy without action collision'); +like($save_source, qr/\$config\{'create_return_index'\} eq '1'.*?index_url\(\$in\{'name'\}, \$user_scope, \$unituser\)/s, + 'save page can return to index after creating a unit'); +like($save_source, qr/deps=1/, + 'save page redirects dependency requests to mass action page'); +like($save_source, qr/props=1/, + 'save page redirects property requests to mass action page'); +unlike($save_source, qr/name="conf"|ui_textarea\("conf"/, + 'save/edit flow no longer uses conf textarea name'); +like($save_source, qr/systemd_can_create/, + 'save page checks create ACLs'); +like($save_source, qr/get_creatable_unit_types\(\$user_scope\)/, + 'save page rejects user-scope-only unsupported unit types'); +like($save_source, qr/\$user_scope.*?socket_user.*?socket_group/s, + 'save page strips socket owner fields for user units'); +like($save_source, qr/systemd_can_dropin/, + 'save page checks drop-in ACLs'); +like($save_source, qr/systemd_can_delete/, + 'save page checks delete ACLs'); +like($edit_source, qr/\$config\{'default_linger'\}/, + 'new user-unit form honors configured default linger setting'); +like($edit_source, qr/systemd_linger_user/, + 'user-unit forms use user-friendly linger wording'); +like($edit_source, qr/get_creatable_unit_types\(\$create_user_scope\)/, + 'new user-unit form limits unit types by selected scope'); +like($edit_source, qr/systemd_type_user/, + 'new user-unit form links to user-scope unit type help'); +like($edit_source, qr/sub path_unit_placeholders\b.*?/s, + 'new unit form has scoped path-unit placeholders'); +like($edit_source, qr/\/run\/user\/.*?\.config\/my-app\.conf/s, + 'new user path-unit form suggests user runtime and home paths'); +like($edit_source, qr/sub service_unit_placeholders\b.*?user_scope_example_paths.*?\.config\/my-app\/env/s, + 'new user service form suggests user runtime and home paths'); +like($edit_source, qr/ui_select\("restart_policy"/, + 'new service form avoids restart action field collision'); +unlike($edit_source, qr/ui_select\("restart"/, + 'new service form does not name restart policy like an action button'); +like($edit_source, qr/systemdExtraPlaceholders.*?timer: 'OnUnitInactiveSec=.*?socket: 'Backlog=/s, + 'new unit form has type-specific advanced placeholders'); +like($edit_source, qr/slice: 'CPUQuota=50%\\nMemoryHigh=256M'/, + 'new slice advanced placeholder avoids structured slice fields'); +unlike($edit_source, qr/slice: 'CPUWeight=.*MemoryMax=.*TasksMax=/s, + 'new slice advanced placeholder does not duplicate guided fields'); +unlike($edit_source, + qr/const systemdExtraPlaceholders = \{(?:(?!\n\t\};).)*target:/s, + 'new target unit form has no invalid target-body placeholder'); +like($edit_source, qr/const extra = !service && type != 'target'/, + 'new target unit form hides type-specific body field'); +like($edit_source, qr/sub socket_unit_placeholders\b.*?user_scope_example_paths/s, + 'new user socket form suggests user runtime paths'); +like($edit_source, qr/systemd_socket_user_row.*?showrow\('systemd_socket_user_row', !enabled && socket\)/s, + 'new user socket form hides socket ownership fields'); +like($edit_source, qr/force_user_scope_create.*ui_hidden\("userservice", 1\)/s, + 'new user-unit form fixes user scope in non-root user mode'); +like($edit_source, qr/force_user_scope_owner.*ui_hidden\("unituser", \$default_unituser\)/s, + 'new user-unit form hides fixed user owner in non-root user mode'); +like($edit_source, qr/sub edit_runtime_state\b/, + 'edit page formats systemd runtime states'); +like($edit_source, qr/systemd_runtime_state.*systemd_unit_state/s, + 'edit page shows runtime and unit-file states in display order'); +like($edit_source, + qr/systemd_runtime_state.*systemd_runtime_state.*systemd_main_pid.*systemd_main_pid.*systemd_unit_state.*systemd_unit_state/s, + 'edit page links status rows to matching help titles'); +like($edit_source, + qr/systemd_unituser.*systemd_runtime_state.*systemd_unit_state.*systemd_boot.*systemd_linger_user/s, + 'edit page orders user unit metadata rows'); +like($edit_source, qr/readonly='readonly'/, + 'edit page shows runtime-managed unit files as read-only'); +like($edit_source, qr/unit_file_editable/, + 'edit page hides save and delete for runtime-managed unit files'); +like($edit_source, qr/edit_depsnow/, + 'edit page includes dependency inspect action'); +like($edit_source, qr/edit_propsnow/, + 'edit page includes property inspect action'); +like($edit_source, qr/edit_overridenow/, + 'edit page includes override creation action'); +like($edit_source, qr/edit_editoverridenow/, + 'edit page labels existing overrides as editable'); +like($edit_source, qr/edit_deleteoverridenow/, + 'edit page labels override deletes clearly'); +like($edit_source, qr/edit_stockunitnow/, + 'edit page links override edits back to the stock unit'); +like($edit_source, qr/stock_unit/, + 'edit page uses a grouped button for stock-unit navigation'); +like($edit_source, qr/systemd_can_edit/, + 'edit page gates writable unit editor controls'); +like($edit_source, qr/systemd_can_linger/, + 'edit page gates linger controls'); +like($lib_source, qr/sub dropin_exists\b/, + 'library detects existing override files safely'); +like($lib_source, qr/sub list_system_dropin_override_files\b/, + 'library lists system drop-in override files'); +like($lib_source, qr/sub list_user_dropin_override_files\b/, + 'library lists user drop-in override files'); +like($lib_source, qr/sub write_system_dropin_config_file\b/, + 'library writes exact system drop-in config files'); +like($lib_source, qr/sub write_user_dropin_config_file\b/, + 'library writes exact user drop-in config files'); +like($edit_source, qr/dropin_exists/, + 'edit page uses shared override detection'); +like($index_source, qr/index_edit_url.*dropin_exists/s, + 'index page links units with existing overrides to the override file'); +like($edit_source, qr/read_system_dropin_file/, + 'edit page can open system override files'); +like($edit_source, qr/read_user_dropin_file/, + 'edit page can open user override files'); +like($edit_source, qr/read_system_dropin_config_file/, + 'edit page can open exact system drop-in files'); +like($edit_source, qr/read_user_dropin_config_file/, + 'edit page can open exact user drop-in files'); +like($edit_source, qr/ui_hidden\("dropfile"/, + 'edit page preserves exact drop-in file selections'); +unlike($edit_source, qr/ui_yesno_radio\("dropin"/, + 'edit page does not use a drop-in save toggle row'); +like($edit_manual_source, qr/list_manual_unit_files/, + 'manual editor lists constrained unit files'); +like($lib_source, qr/list_system_dropin_override_files/, + 'manual editor allowlist can include system drop-in files'); +like($lib_source, qr/list_all_user_dropin_override_files/, + 'manual editor allowlist can include user drop-in files'); +like($lib_source, qr/verify_dropin_data/, + 'manual editor validates drop-in files as drop-ins'); +like($edit_manual_source, qr/ui_table_start\(undef, undef, 2\)/, + 'manual editor uses plain textarea table without a header'); +unlike($edit_manual_source, qr/manual_user_file/, + 'manual editor selector shows raw file paths without user prefixes'); +like($edit_manual_source, qr/manual_desc_user/, + 'manual editor shows a user-specific description for user files'); +like($edit_manual_source, qr/manual_desc/, + 'manual editor shows a system description for system files'); +like($edit_manual_source, qr/manual_empty_message/, + 'manual editor has an empty-state message for missing files'); +like($edit_manual_source, qr/manual_edit_err/, + 'manual editor uses an edit-specific error title'); +unlike($edit_manual_source, qr/action_links\(/, + 'manual editor header omits daemon reload action'); +like($edit_manual_source, qr/systemd_can_manual/, + 'manual editor filters files by ACL'); +like($dropins_source, qr/list_system_dropin_override_files/, + 'drop-in inventory lists system drop-ins'); +like($dropins_source, qr/list_all_user_dropin_override_files/, + 'drop-in inventory lists user drop-ins'); +like($dropins_source, qr/systemd_acl_user_allowed/, + 'drop-in inventory filters user rows by ACL owner rules'); +like($dropins_source, qr/systemd_can_dropin/, + 'drop-in inventory gates edit links with drop-in ACLs'); +like($dropins_source, qr/sub dropin_file_arg\b/, + 'drop-in inventory links non-standard drop-ins by exact file'); +like($save_manual_source, qr/write_manual_unit_file/, + 'manual save uses constrained unit file writer'); +like($save_manual_source, qr/mark_units_changed/, + 'manual system save marks daemon reload as needed'); +like($save_manual_source, qr/mark_user_units_changed/, + 'manual user save marks user manager reload as needed'); +like($save_manual_source, qr/systemd_can_manual/, + 'manual save checks manual ACLs'); +like($lib_source, qr/sub create_system_unit\b.*?verify_unit_data\(.*?, 0\)/s, + 'system unit creation verifies rendered unit data'); +like($lib_source, qr/sub create_user_unit\b.*?verify_unit_data\(.*?, 1, \$user\)/s, + 'user unit creation verifies rendered unit data in user mode'); +like($type_help_source, qr/mount<\/tt>.*swap<\/tt>.*slice<\/tt>/s, + 'system unit type help documents privileged unit types'); +like($type_user_help_source, qr/service<\/tt>.*timer<\/tt>.*slice<\/tt>/s, + 'user unit type help documents available user unit types'); +like($type_user_help_source, qr/Mount, automount and swap units.*not\s+available/s, + 'user unit type help explains unavailable storage unit types'); +like($path_help_source, qr/For user units.*home directory.*runtime directory/s, + 'path unit help explains user-scope path location'); +like($socket_fifo_help_source, qr/For user units.*\/run\/user\/UID/s, + 'socket FIFO help explains user-scope path location'); +like($socket_user_help_source, qr/For user units.*owned by that user/s, + 'socket owner help explains user-scope ownership'); +like($workdir_help_source, qr/For user units.*home directory/s, + 'service working directory help explains user-scope paths'); +like($workdir_help_source, qr/path beginning with\s*~<\/tt>.*leading\s*-<\/tt>/s, + 'working directory help documents accepted tilde and dash prefixes'); +like($envfile_help_source, qr/Absolute path.*Prefix the path with\s*-<\/tt>/s, + 'environment file help documents absolute paths and dash prefix'); +unlike($envfile_help_source, qr/~\//, + 'environment file help avoids unsupported tilde-path examples'); +like($limitnofile_help_source, qr/infinity<\/tt>.*soft:hard/s, + 'open-files help documents infinity and soft-hard forms'); +like($logstd_help_source, qr/append:\/path\/to\/file<\/tt>.*absolute file path/s, + 'standard output help documents appended absolute paths'); +like($logstd_help_source, qr/fd:name<\/tt>/, + 'standard output help documents advanced systemd targets'); +like($logerr_help_source, qr/truncate:\/path\/to\/file<\/tt>.*fd:name<\/tt>/s, + 'standard error help documents accepted output targets'); +like($socketaccept_help_source, qr/one service\s+instance.*incoming connection/s, + 'socket accept help explains per-connection instances'); +like($socketservice_help_source, qr/Accept=yes<\/tt>.*example\@\.service<\/tt>/s, + 'socket service help documents template services for accept mode'); +like($timerpersistent_help_source, qr/For calendar timers.*does not catch up monotonic/s, + 'persistent timer help distinguishes calendar and monotonic timers'); +like($conf_help_source, qr/drop-in override.*system or user systemd manager/s, + 'unit configuration help covers drop-ins and scoped reloads'); +like($file_help_source, qr/drop-in override file.*vendor unit files/s, + 'configuration file help explains drop-ins and vendor files'); +like($readwritepaths_help_source, qr/ProtectSystem=.*optional paths/s, + 'writable paths help connects protection and optional path prefixes'); +like($unitconf_help_source, qr/For user units.*\/run\/user\/UID.*?resource controls apply/s, + 'type-specific help includes user-scope socket and slice notes'); +like($slice_help_source, qr/For user units.*parent cgroup/s, + 'slice help explains user-scope resource boundary'); +like($restart_source, qr/daemon-reload/, + 'restart page runs systemctl daemon-reload'); +like($restart_source, qr/mark_daemon_reloaded/, + 'restart page clears daemon reload reminder'); +like($restart_source, qr/systemd_can_reload/, + 'restart page checks reload ACL'); +like($restart_user_source, qr/reload_user_manager/, + 'user restart page reloads the user manager'); +like($restart_user_source, qr/mark_user_daemon_reloaded/, + 'user restart page clears user manager reload reminder'); +like($restart_user_source, qr/systemd_can_reload_user/, + 'user restart page checks scoped reload ACL'); + +done_testing();