diff --git a/config/config.ini.example b/config/config.ini.example index 9ddfd9c..7eea6ba 100644 --- a/config/config.ini.example +++ b/config/config.ini.example @@ -79,6 +79,15 @@ hostname_verification = 0 ; 2: Only a link to the public key web page will be added to each key comment_key_files = 1 +; Add environment="" to synced user key lines. +; 1: enabled by default (can be overridden per server) +; 0: disabled by default (can be overridden per server) +history_username_env_default = 1 + +; Format for the environment value. +; Supported placeholder: {uid} (required, otherwise a safe default is used) +history_username_env_format = BASH_HISTORY_USERNAME={uid} + [monitoring] ; Status information is stored in the following file on target machines: status_file_path = "/var/local/keys-sync.status" diff --git a/history_username_env_common.php b/history_username_env_common.php new file mode 100644 index 0000000..338daa8 --- /dev/null +++ b/history_username_env_common.php @@ -0,0 +1,21 @@ +database->query(" +ALTER TABLE `server` +ADD `history_username_env_mode` enum('inherit', 'enabled', 'disabled') NOT NULL DEFAULT 'inherit', +ADD `history_username_env_format` varchar(255) NULL; +"); diff --git a/model/migrationdirectory.php b/model/migrationdirectory.php index 5f19943..8056317 100644 --- a/model/migrationdirectory.php +++ b/model/migrationdirectory.php @@ -22,7 +22,7 @@ class MigrationDirectory extends DBDirectory { /** * Increment this constant to activate a new migration from the migrations directory */ - const LAST_MIGRATION = 9; + const LAST_MIGRATION = 10; public function __construct() { parent::__construct(); diff --git a/model/server.php b/model/server.php index dac4d93..ad7ec79 100644 --- a/model/server.php +++ b/model/server.php @@ -62,6 +62,8 @@ public function update() { case 'key_management': case 'authorization': case 'custom_keys': + case 'history_username_env_mode': + case 'history_username_env_format': $resync = true; break; case 'host_key': @@ -824,4 +826,4 @@ public function parse_jumphosts(): array { } class ServerNoteNotFoundException extends Exception {} -class AccountNameInvalid extends InvalidArgumentException {} \ No newline at end of file +class AccountNameInvalid extends InvalidArgumentException {} diff --git a/public_html/extra.js b/public_html/extra.js index faa3b74..d13acf2 100644 --- a/public_html/extra.js +++ b/public_html/extra.js @@ -151,12 +151,14 @@ $(function() { form.each(function() { $('#authorization.hide').hide().removeClass('hide'); $('#ldap_access_options.hide').hide().removeClass('hide'); + $('#history_username_env.hide').hide().removeClass('hide'); $("input[name='key_management']", form).on('click', function() {display_relevant_options()}); $("input[name='authorization']", form).on('click', function() {display_relevant_options()}); function display_relevant_options() { if($("input[name='key_management']:checked").val() == 'keys') { $('#authorization').show('fast'); $('#supervision').show('fast'); + $('#history_username_env').show('fast'); if($("input[name='authorization']:checked").val() == 'manual') { $('#ldap_access_options').hide('fast'); } else { @@ -166,6 +168,7 @@ $(function() { $('#authorization').hide('fast'); $('#ldap_access_options').hide('fast'); $('#supervision').hide('fast'); + $('#history_username_env').hide('fast'); } } diff --git a/scripts/sync.php b/scripts/sync.php index c70d626..160cbf5 100755 --- a/scripts/sync.php +++ b/scripts/sync.php @@ -19,6 +19,7 @@ chdir(__DIR__); require('../core.php'); +require_once(__DIR__.'/../history_username_env_common.php'); require('sync-common.php'); require('ssh.php'); $required_files = array('config/keys-sync', 'config/keys-sync.pub'); @@ -325,25 +326,25 @@ function sync_server($id, $only_username = null, $preview = false) { foreach($accounts as $account) { if($account->active == 0 || $account->sync_status == 'proposed') continue; $username = str_replace('/', '', $account->name); - $keyfile = sprintf($header, "account '{$account->name}'", $config['web']['baseurl']."/servers/".urlencode($hostname)."/accounts/".urlencode($account->name)); - // Collect a set of all groups that the account is a member of (directly or indirectly) and the account itself - $sets = $account->list_group_membership(); - $sets[] = $account; - foreach($sets as $set) { - if(get_class($set) == 'Group') { - if($set->active == 0) continue; // Rules for inactive groups should be ignored - if ($comment == 1) { - $keyfile .= "# === Start of rules applied due to membership in {$set->name} group ===\n"; + $keyfile = sprintf($header, "account '{$account->name}'", $config['web']['baseurl']."/servers/".urlencode($hostname)."/accounts/".urlencode($account->name)); + // Collect a set of all groups that the account is a member of (directly or indirectly) and the account itself + $sets = $account->list_group_membership(); + $sets[] = $account; + foreach($sets as $set) { + if(get_class($set) == 'Group') { + if($set->active == 0) continue; // Rules for inactive groups should be ignored + if ($comment == 1) { + $keyfile .= "# === Start of rules applied due to membership in {$set->name} group ===\n"; + } + } + $access_rules = $set->list_access(); + $keyfile .= get_keys($access_rules, $account->name, $hostname, $comment, $server); + if(get_class($set) == 'Group' && $comment == 1) { + $keyfile .= "# === End of rules applied due to membership in {$set->name} group ===\n\n"; } } - $access_rules = $set->list_access(); - $keyfile .= get_keys($access_rules, $account->name, $hostname, $comment); - if(get_class($set) == 'Group' && $comment == 1) { - $keyfile .= "# === End of rules applied due to membership in {$set->name} group ===\n\n"; - } + $keyfiles[$username] = array('keyfile' => $keyfile, 'check' => false, 'account' => $account); } - $keyfiles[$username] = array('keyfile' => $keyfile, 'check' => false, 'account' => $account); - } if($server->authorization == 'automatic LDAP' || $server->authorization == 'manual LDAP') { // Generate keyfiles for LDAP users $optiontext = array(); @@ -361,8 +362,9 @@ function sync_server($id, $only_username = null, $preview = false) { $keys = $user->list_public_keys($username, $hostname, false); if(count($keys) > 0) { if($user->active) { + $user_prefix = add_user_history_username_env_option($prefix, $user, $server); foreach($keys as $key) { - $keyfile .= $prefix.$key->export_userkey_with_fixed_comment($user, $comment)."\n"; + $keyfile .= $user_prefix.$key->export_userkey_with_fixed_comment($user, $comment)."\n"; } } elseif ($comment == 1) { $keyfile .= "# Account disabled\n"; @@ -494,7 +496,123 @@ function($reason) use ($server, $keyfiles) { echo date('c')." {$hostname}: Sync finished\n"; } -function append_user_keys($keyfile, $entity, $prefix, $account_name, $hostname, $comment, $grant_details = null) { +function get_default_history_username_env_format() { + return 'BASH_HISTORY_USERNAME={uid}'; +} + +function history_username_env_value_is_valid($value) { + if($value === '') { + return false; + } + if(preg_match('/[\r\n,\'"\\\\{}]/', $value)) { + return false; + } + return preg_match('/^[A-Za-z0-9 ._@:+=-]+$/', $value) === 1; +} + +function normalize_history_username_env_format($format) { + $format = trim((string)$format); + if(!history_username_env_format_is_valid($format)) { + return get_default_history_username_env_format(); + } + return $format; +} + +function get_global_history_username_env_enabled() { + global $config; + if(!isset($config['privacy']) || !isset($config['privacy']['history_username_env_default'])) { + return false; + } + return intval($config['privacy']['history_username_env_default']) === 1; +} + +function get_global_history_username_env_format() { + global $config; + if(isset($config['privacy']) && isset($config['privacy']['history_username_env_format'])) { + return normalize_history_username_env_format($config['privacy']['history_username_env_format']); + } + return get_default_history_username_env_format(); +} + +function get_server_history_username_env_mode($server) { + try { + $mode = $server->history_username_env_mode; + } catch(Exception $e) { + return 'inherit'; + } + if($mode !== 'enabled' && $mode !== 'disabled') { + return 'inherit'; + } + return $mode; +} + +function get_server_history_username_env_enabled($server) { + $mode = get_server_history_username_env_mode($server); + switch($mode) { + case 'enabled': + return true; + case 'disabled': + return false; + default: + return get_global_history_username_env_enabled(); + } +} + +function get_server_history_username_env_format($server) { + try { + $format = trim((string)$server->history_username_env_format); + } catch(Exception $e) { + $format = ''; + } + if($format !== '') { + return normalize_history_username_env_format($format); + } + return get_global_history_username_env_format(); +} + +function escape_authorized_keys_option_value($value) { + $value = preg_replace('/[[:cntrl:]]+/', '', (string)$value); + if($value === null) { + $value = ''; + } + if(!history_username_env_value_is_valid($value)) { + throw new InvalidArgumentException('Invalid history username environment value'); + } + return str_replace(array('\\', '"'), array('\\\\', '\\"'), $value); +} + +function append_authorized_keys_option($prefix, $option) { + $prefix = trim((string)$prefix); + if($prefix === '') { + return $option.' '; + } + return rtrim($prefix, ',').','.$option.' '; +} + +function get_user_history_username_env_option($user, $server) { + if(!get_server_history_username_env_enabled($server)) { + return null; + } + $value = str_replace('{uid}', $user->uid, get_server_history_username_env_format($server)); + if(!history_username_env_value_is_valid($value)) { + return null; + } + try { + return 'environment="'.escape_authorized_keys_option_value($value).'"'; + } catch(InvalidArgumentException $e) { + return null; + } +} + +function add_user_history_username_env_option($prefix, $user, $server) { + $option = get_user_history_username_env_option($user, $server); + if(is_null($option)) { + return $prefix; + } + return append_authorized_keys_option($prefix, $option); +} + +function append_user_keys($keyfile, $entity, $prefix, $account_name, $hostname, $comment, $server, $grant_details = null) { if ($comment == 1) { $keyfile .= "# {$entity->uid}"; if (!is_null($grant_details)) { @@ -503,6 +621,7 @@ function append_user_keys($keyfile, $entity, $prefix, $account_name, $hostname, $keyfile .= "\n"; } if($entity->active) { + $prefix = add_user_history_username_env_option($prefix, $entity, $server); $keys = $entity->list_public_keys($account_name, $hostname, false); foreach($keys as $key) { $keyfile .= $prefix.$key->export_userkey_with_fixed_comment($entity, $comment)."\n"; @@ -532,7 +651,7 @@ function append_serveraccount_keys($keyfile, $entity, $prefix, $account_name, $h return $keyfile; } -function get_keys($access_rules, $account_name, $hostname, $comment) { +function get_keys($access_rules, $account_name, $hostname, $comment, $server) { $keyfile = ''; foreach($access_rules as $access) { $grant_date = new DateTime($access->grant_date); @@ -553,6 +672,7 @@ function get_keys($access_rules, $account_name, $hostname, $comment) { $account_name, $hostname, $comment, + $server, "granted access by {$access->granted_by->uid} on {$grant_date_full}" ); break; @@ -579,7 +699,7 @@ function get_keys($access_rules, $account_name, $hostname, $comment) { if ($comment == 1) { $keyfile .= "# == Start of {$entity->name} group members ==\n"; } - $keyfile .= get_group_keys($entity->list_members(), $account_name, $hostname, $prefix, $seen, $comment); + $keyfile .= get_group_keys($entity->list_members(), $account_name, $hostname, $prefix, $seen, $comment, $server); if ($comment == 1) { $keyfile .= "# == End of {$entity->name} group members ==\n"; } @@ -592,7 +712,7 @@ function get_keys($access_rules, $account_name, $hostname, $comment) { return $keyfile; } -function get_group_keys($entities, $account_name, $hostname, $prefix, &$seen, $comment) { +function get_group_keys($entities, $account_name, $hostname, $prefix, &$seen, $comment, $server) { $keyfile = ''; foreach($entities as $entity) { switch(get_class($entity)) { @@ -603,7 +723,8 @@ function get_group_keys($entities, $account_name, $hostname, $prefix, &$seen, $c $prefix, $account_name, $hostname, - $comment + $comment, + $server ); break; case 'ServerAccount': @@ -625,7 +746,7 @@ function get_group_keys($entities, $account_name, $hostname, $prefix, &$seen, $c $keyfile .= "\n"; $keyfile .= "# == Start of {$entity->name} group members ==\n"; } - $keyfile .= get_group_keys($entity->list_members(), $account_name, $hostname, $prefix, $seen, $comment); + $keyfile .= get_group_keys($entity->list_members(), $account_name, $hostname, $prefix, $seen, $comment, $server); if ($comment == 1) { $keyfile .= "# == End of {$entity->name} group members ==\n"; } diff --git a/templates/server.php b/templates/server.php index 4dcb9cd..8650ddb 100644 --- a/templates/server.php +++ b/templates/server.php @@ -369,8 +369,8 @@ - get('ldap_access_options'); ?> -
+ get('ldap_access_options'); ?> +
@@ -387,16 +387,49 @@
-
- +
+ +
-
-
-
- + get('server')->history_username_env_mode; + if($history_username_env_mode != 'enabled' && $history_username_env_mode != 'disabled') { + $history_username_env_mode = 'inherit'; + } + $history_username_env_format = trim((string)$this->get('server')->history_username_env_format); + ?> +
+ +
+
+ +
+
+ +
+
+ +
+ + +

Supported placeholder: {uid}. If missing, sync falls back to BASH_HISTORY_USERNAME={uid}.

+
+
+
+
+ +
-
SSH port number
@@ -424,8 +457,8 @@ } ?> - get('server')->key_management == 'keys' && $this->get('server')->authorization != 'manual') { ?> -
LDAP access options
+ get('server')->key_management == 'keys' && $this->get('server')->authorization != 'manual') { ?> +
LDAP access options
-
- -
+ + + get('server')->key_management == 'keys') { ?> +
History username env
+
+ get('server')->history_username_env_mode; + $history_username_env_format = trim((string)$this->get('server')->history_username_env_format); + switch($history_username_env_mode) { + case 'enabled': + out('Force enabled'); + break; + case 'disabled': + out('Force disabled'); + break; + default: + out('Inherit global default'); + } + out(' | Format: '); + if($history_username_env_format === '') { + out('Inherit global format'); + } else { + out($history_username_env_format); + } + ?> +
+ + get('server_admin_can_reset_host_key')) { ?>
diff --git a/views/server.php b/views/server.php index 8b910a5..bfa0acd 100644 --- a/views/server.php +++ b/views/server.php @@ -37,6 +37,7 @@ $all_accounts = $server->list_accounts(); $ldap_access_options = $server->list_ldap_access_options(); $server_admin_can_reset_host_key = (isset($config['security']) && isset($config['security']['host_key_reset_restriction']) && $config['security']['host_key_reset_restriction'] == 0); +require_once('history_username_env_common.php'); if(isset($_POST['sync'])) { $server->sync_access(); @@ -121,6 +122,25 @@ $server->key_management = $_POST['key_management']; $server->authorization = $_POST['authorization']; $server->key_scan = $_POST['key_scan']; + $history_username_env_mode = isset($_POST['history_username_env_mode']) ? $_POST['history_username_env_mode'] : 'inherit'; + if($history_username_env_mode !== 'inherit' && $history_username_env_mode !== 'enabled' && $history_username_env_mode !== 'disabled') { + $history_username_env_mode = 'inherit'; + } + $history_username_env_format = null; + if(isset($_POST['history_username_env_format'])) { + $history_username_env_format = trim($_POST['history_username_env_format']); + if($history_username_env_format === '') { + $history_username_env_format = null; + } elseif(!history_username_env_format_is_valid($history_username_env_format)) { + $alert = new UserAlert; + $alert->content = "Invalid history username env format. Allowed characters: letters, digits, spaces, dot (.), underscore (_), hyphen (-), at sign (@), colon (:), plus (+), equals (=), and braces for {uid}. The format must include both '=' and {uid}."; + $alert->class = 'danger'; + $active_user->add_alert($alert); + $history_username_env_format = null; + } + } + $server->history_username_env_mode = $history_username_env_mode; + $server->history_username_env_format = $history_username_env_format; try { $server->update(); $alert = new UserAlert;