100100
101101$ pending_syncs = array ();
102102foreach ($ servers as $ server ) {
103- if ($ server ->key_management != 'keys ' ) {
103+ if ($ server ->key_management != 'keys ' && $ server -> key_management != ' decommissioned ' ) {
104104 continue ;
105105 }
106106 $ pending_syncs [$ server ->hostname ] = $ server ;
@@ -153,6 +153,146 @@ function show_help() {
153153<?php
154154}
155155
156+ /**
157+ * Establish an SSH connection while handling hanging connection attempts uniformly.
158+ */
159+ function connect_ssh_with_timeout_handling ($ server , $ hostname , callable $ on_timeout , callable $ on_connect_failure ) {
160+ // ssh2 sometimes hangs on connect; use SIGTERM from wrapper timeout to bail out cleanly
161+ declare (ticks = 1 );
162+ pcntl_signal (SIGTERM , function ($ signal ) use ($ server , $ hostname , $ on_timeout ) {
163+ echo date ('c ' )." {$ hostname }: SSH connection timed out. \n" ;
164+ $ on_timeout ();
165+ exit (0 );
166+ });
167+
168+ echo date ('c ' )." {$ hostname }: Attempting to connect. \n" ;
169+ try {
170+ $ connection = $ server ->connect_ssh ();
171+ } catch (SSHException $ e ) {
172+ $ reason = describe_oneline ($ e );
173+ echo date ('c ' )." {$ hostname }: $ reason \n" ;
174+ $ on_connect_failure ($ reason );
175+ return null ;
176+ }
177+
178+ // From this point on, catch SIGTERM and ignore. SIGINT or SIGKILL is required to stop, so timeout wrapper won't
179+ // cause a partial sync/decommission
180+ pcntl_signal (SIGTERM , SIG_IGN );
181+
182+ return $ connection ;
183+ }
184+
185+ function decommission_server ($ id , $ preview = false ) {
186+ global $ server_dir ;
187+
188+ $ keydir = '/var/local/keys-sync ' ;
189+ $ server = $ server_dir ->get_server_by_id ($ id );
190+ $ hostname = $ server ->hostname ;
191+ echo date ('c ' )." {$ hostname }: Server is decommissioned, removing all keys (preserving keys-sync access). \n" ;
192+
193+ if ($ preview ) {
194+ echo date ('c ' )." {$ hostname }: [PREVIEW] Would remove all key files from {$ keydir }/ except 'keys-sync' and '.hostnames' \n" ;
195+ return ;
196+ }
197+
198+ $ connection = connect_ssh_with_timeout_handling (
199+ $ server ,
200+ $ hostname ,
201+ function () use ($ server ) {
202+ $ server ->sync_report ('sync failure ' , 'SSH connection timed out during decommission ' );
203+ $ server ->reschedule_sync_request ();
204+ },
205+ function ($ reason ) use ($ server ) {
206+ $ server ->sync_report ('sync failure ' , 'Failed to connect during decommission: ' .$ reason );
207+ $ server ->reschedule_sync_request ();
208+ }
209+ );
210+ if (is_null ($ connection )) {
211+ return ;
212+ }
213+
214+ $ cleanup_errors = 0 ;
215+ $ removed_count = 0 ;
216+
217+ // First verify the directory exists and is accessible (don't suppress errors)
218+ try {
219+ $ connection ->exec ('test -d ' .escapeshellarg ($ keydir ).' && test -r ' .escapeshellarg ($ keydir ));
220+ } catch (SSHException $ e ) {
221+ $ cleanup_errors ++;
222+ echo date ('c ' )." {$ hostname }: Cannot access key directory: " .describe_oneline ($ e )."\n" ;
223+ $ server ->sync_report ('sync failure ' , 'Cannot access key directory during decommission: ' .describe_oneline ($ e ));
224+ $ server ->reschedule_sync_request ();
225+ return ;
226+ }
227+
228+ // Get list of all files in the key directory (don't suppress errors)
229+ try {
230+ // Try sha1sum first without suppressing stderr
231+ $ output = $ connection ->exec ('/usr/bin/sha1sum ' .escapeshellarg ($ keydir ).'/* 2>&1 ' );
232+ $ entries = explode ("\n" , $ output );
233+ $ files_to_check = array ();
234+ foreach ($ entries as $ entry ) {
235+ // Check for error messages
236+ if (strpos ($ entry , 'No such file ' ) !== false || strpos ($ entry , 'Permission denied ' ) !== false ) {
237+ // sha1sum failed due to access issues, try ls instead
238+ break ;
239+ }
240+ if (preg_match ('|^([0-9a-f]{40}) ' .preg_quote ($ keydir , '| ' ).'/(.*)$| ' , $ entry , $ matches )) {
241+ $ files_to_check [] = $ matches [2 ];
242+ }
243+ }
244+
245+ // If sha1sum didn't work or found no files, try ls directly (don't suppress errors)
246+ if (empty ($ files_to_check )) {
247+ $ output = $ connection ->exec ('ls -1 ' .escapeshellarg ($ keydir ).' 2>&1 ' );
248+ // Check if ls output contains error messages
249+ if (strpos ($ output , 'No such file ' ) !== false || strpos ($ output , 'Permission denied ' ) !== false ) {
250+ throw new SSHException ("Failed to list files in key directory: {$ output }" );
251+ }
252+ $ files_to_check = array_filter (explode ("\n" , trim ($ output )), function ($ f ) { return trim ($ f ) !== '' ; });
253+ }
254+
255+ // Remove all files except keys-sync and .hostnames
256+ foreach ($ files_to_check as $ file ) {
257+ $ file = trim ($ file );
258+ if ($ file == '' || $ file == 'keys-sync ' || $ file == '.hostnames ' ) {
259+ continue ;
260+ }
261+ try {
262+ $ connection ->unlink ("$ keydir/ $ file " );
263+ echo date ('c ' )." {$ hostname }: Removed key file: {$ file }\n" ;
264+ $ removed_count ++;
265+ } catch (SSHException $ e ) {
266+ $ cleanup_errors ++;
267+ echo date ('c ' )." {$ hostname }: Couldn't remove key file {$ file }: " .describe_oneline ($ e )."\n" ;
268+ }
269+ }
270+
271+ if ($ removed_count == 0 ) {
272+ echo date ('c ' )." {$ hostname }: No key files found to remove (directory is empty or contains only protected files) \n" ;
273+ }
274+ } catch (SSHException $ e ) {
275+ $ cleanup_errors ++;
276+ echo date ('c ' )." {$ hostname }: Error listing key files: " .describe_oneline ($ e )."\n" ;
277+ }
278+
279+ // Update status
280+ if ($ cleanup_errors > 0 ) {
281+ $ server ->sync_report ('sync failure ' , 'Failed to remove ' .$ cleanup_errors .' key file ' .($ cleanup_errors == 1 ? '' : 's ' ).' during decommission ' );
282+ $ server ->reschedule_sync_request ();
283+ } else {
284+ $ server ->sync_report ('sync success ' , 'Decommissioned: removed ' .$ removed_count .' key file ' .($ removed_count == 1 ? '' : 's ' ).' (keys-sync access preserved) ' );
285+ }
286+
287+ try {
288+ $ server ->update_status_file ($ connection );
289+ } catch (SSHException $ e ) {
290+ // Ignore status file update errors during decommission
291+ }
292+
293+ echo date ('c ' )." {$ hostname }: Decommission completed \n" ;
294+ }
295+
156296function sync_server ($ id , $ only_username = null , $ preview = false ) {
157297 global $ config ;
158298 global $ server_dir ;
@@ -170,6 +310,13 @@ function sync_server($id, $only_username = null, $preview = false) {
170310 $ server = $ server_dir ->get_server_by_id ($ id );
171311 $ hostname = $ server ->hostname ;
172312 echo date ('c ' )." {$ hostname }: Preparing sync. \n" ;
313+
314+ // Handle decommissioned servers: remove all keys except keys-sync
315+ if ($ server ->key_management == 'decommissioned ' ) {
316+ decommission_server ($ id , $ preview );
317+ return ;
318+ }
319+
173320 if ($ server ->key_management != 'keys ' ) return ;
174321 $ accounts = $ server ->list_accounts ();
175322 $ keyfiles = array ();
@@ -237,36 +384,24 @@ function sync_server($id, $only_username = null, $preview = false) {
237384 return ;
238385 }
239386
240- // This is working around deficiencies in the ssh2 library. In some cases, ssh connection attempts will fail, and
241- // the socket timeout of 60 seconds is somehow not triggered. Script execution timeout is also not triggered.
242- // Reproducing this problem is not easy - dropping packets to port 22 is not sufficient (it will timeout correctly).
243- // To workaround, we wrap calls to this script with 'timeout' shell command, and from this point on until we have
244- // established a connection, catch SIGTERM and report server sync failure if received
245- declare (ticks = 1 );
246- pcntl_signal (SIGTERM , function ($ signal ) use ($ server , $ hostname , $ keyfiles ) {
247- echo date ('c ' )." {$ hostname }: SSH connection timed out. \n" ;
248- $ server ->sync_report ('sync failure ' , 'SSH connection timed out ' );
249- $ server ->reschedule_sync_request ();
250- report_all_accounts_failed ($ keyfiles );
251- exit (0 );
252- });
253-
254- echo date ('c ' )." {$ hostname }: Attempting to connect. \n" ;
255- try {
256- $ connection = $ server ->connect_ssh ();
257- } catch (SSHException $ e ) {
258- $ reason = describe_oneline ($ e );
259- echo date ('c ' )." {$ hostname }: $ reason \n" ;
260- $ server ->sync_report ('sync failure ' , $ reason );
261- $ server ->reschedule_sync_request ();
262- report_all_accounts_failed ($ keyfiles );
387+ $ connection = connect_ssh_with_timeout_handling (
388+ $ server ,
389+ $ hostname ,
390+ function () use ($ server , $ keyfiles ) {
391+ $ server ->sync_report ('sync failure ' , 'SSH connection timed out ' );
392+ $ server ->reschedule_sync_request ();
393+ report_all_accounts_failed ($ keyfiles );
394+ },
395+ function ($ reason ) use ($ server , $ keyfiles ) {
396+ $ server ->sync_report ('sync failure ' , $ reason );
397+ $ server ->reschedule_sync_request ();
398+ report_all_accounts_failed ($ keyfiles );
399+ }
400+ );
401+ if (is_null ($ connection )) {
263402 return ;
264403 }
265404
266- // From this point on, catch SIGTERM and ignore. SIGINT or SIGKILL is required to stop, so timeout wrapper won't
267- // cause a partial sync
268- pcntl_signal (SIGTERM , SIG_IGN );
269-
270405 $ account_errors = 0 ;
271406 $ cleanup_errors = 0 ;
272407
@@ -359,6 +494,44 @@ function sync_server($id, $only_username = null, $preview = false) {
359494 echo date ('c ' )." {$ hostname }: Sync finished \n" ;
360495}
361496
497+ function append_user_keys ($ keyfile , $ entity , $ prefix , $ account_name , $ hostname , $ comment , $ grant_details = null ) {
498+ if ($ comment == 1 ) {
499+ $ keyfile .= "# {$ entity ->uid }" ;
500+ if (!is_null ($ grant_details )) {
501+ $ keyfile .= " {$ grant_details }" ;
502+ }
503+ $ keyfile .= "\n" ;
504+ }
505+ if ($ entity ->active ) {
506+ $ keys = $ entity ->list_public_keys ($ account_name , $ hostname , false );
507+ foreach ($ keys as $ key ) {
508+ $ keyfile .= $ prefix .$ key ->export_userkey_with_fixed_comment ($ entity , $ comment )."\n" ;
509+ }
510+ } elseif ($ comment == 1 ) {
511+ $ keyfile .= "# Account disabled \n" ;
512+ }
513+ return $ keyfile ;
514+ }
515+
516+ function append_serveraccount_keys ($ keyfile , $ entity , $ prefix , $ account_name , $ hostname , $ comment , $ grant_details = null ) {
517+ if ($ comment == 1 ) {
518+ $ keyfile .= "# {$ entity ->name }@ {$ entity ->server ->hostname }" ;
519+ if (!is_null ($ grant_details )) {
520+ $ keyfile .= " {$ grant_details }" ;
521+ }
522+ $ keyfile .= "\n" ;
523+ }
524+ if ($ entity ->server ->key_management != 'decommissioned ' ) {
525+ $ keys = $ entity ->list_public_keys ($ account_name , $ hostname , false );
526+ foreach ($ keys as $ key ) {
527+ $ keyfile .= $ prefix .$ key ->export_serverkey_with_fixed_comment ($ entity , $ comment )."\n" ;
528+ }
529+ } elseif ($ comment == 1 ) {
530+ $ keyfile .= "# Decommissioned server \n" ;
531+ }
532+ return $ keyfile ;
533+ }
534+
362535function get_keys ($ access_rules , $ account_name , $ hostname , $ comment ) {
363536 $ keyfile = '' ;
364537 foreach ($ access_rules as $ access ) {
@@ -373,34 +546,26 @@ function get_keys($access_rules, $account_name, $hostname, $comment) {
373546 if ($ prefix !== '' ) $ prefix .= ' ' ;
374547 switch (get_class ($ entity )) {
375548 case 'User ' :
376- if ($ comment == 1 ) {
377- $ keyfile .= "# {$ entity ->uid }" ;
378- $ keyfile .= " granted access by {$ access ->granted_by ->uid } on {$ grant_date_full }" ;
379- $ keyfile .= "\n" ;
380- }
381- if ($ entity ->active ) {
382- $ keys = $ entity ->list_public_keys ($ account_name , $ hostname , false );
383- foreach ($ keys as $ key ) {
384- $ keyfile .= $ prefix .$ key ->export_userkey_with_fixed_comment ($ entity , $ comment )."\n" ;
385- }
386- } elseif ($ comment == 1 ) {
387- $ keyfile .= "# Account disabled \n" ;
388- }
549+ $ keyfile = append_user_keys (
550+ $ keyfile ,
551+ $ entity ,
552+ $ prefix ,
553+ $ account_name ,
554+ $ hostname ,
555+ $ comment ,
556+ "granted access by {$ access ->granted_by ->uid } on {$ grant_date_full }"
557+ );
389558 break ;
390559 case 'ServerAccount ' :
391- if ($ comment == 1 ) {
392- $ keyfile .= "# {$ entity ->name }@ {$ entity ->server ->hostname }" ;
393- $ keyfile .= " granted access by {$ access ->granted_by ->uid } on {$ grant_date_full }" ;
394- $ keyfile .= "\n" ;
395- }
396- if ($ entity ->server ->key_management != 'decommissioned ' ) {
397- $ keys = $ entity ->list_public_keys ($ account_name , $ hostname , false );
398- foreach ($ keys as $ key ) {
399- $ keyfile .= $ prefix .$ key ->export_serverkey_with_fixed_comment ($ entity , $ comment )."\n" ;
400- }
401- } elseif ($ comment == 1 ) {
402- $ keyfile .= "# Decommissioned server \n" ;
403- }
560+ $ keyfile = append_serveraccount_keys (
561+ $ keyfile ,
562+ $ entity ,
563+ $ prefix ,
564+ $ account_name ,
565+ $ hostname ,
566+ $ comment ,
567+ "granted access by {$ access ->granted_by ->uid } on {$ grant_date_full }"
568+ );
404569 break ;
405570 case 'Group ' :
406571 // Recurse!
@@ -432,32 +597,24 @@ function get_group_keys($entities, $account_name, $hostname, $prefix, &$seen, $c
432597 foreach ($ entities as $ entity ) {
433598 switch (get_class ($ entity )) {
434599 case 'User ' :
435- if ($ comment == 1 ) {
436- $ keyfile .= "# {$ entity ->uid }" ;
437- $ keyfile .= "\n" ;
438- }
439- if ($ entity ->active ) {
440- $ keys = $ entity ->list_public_keys ($ account_name , $ hostname , false );
441- foreach ($ keys as $ key ) {
442- $ keyfile .= $ prefix .$ key ->export_userkey_with_fixed_comment ($ entity , $ comment )."\n" ;
443- }
444- } elseif ($ comment == 1 ) {
445- $ keyfile .= "# Account disabled \n" ;
446- }
600+ $ keyfile = append_user_keys (
601+ $ keyfile ,
602+ $ entity ,
603+ $ prefix ,
604+ $ account_name ,
605+ $ hostname ,
606+ $ comment
607+ );
447608 break ;
448609 case 'ServerAccount ' :
449- if ($ comment == 1 ) {
450- $ keyfile .= "# {$ entity ->name }@ {$ entity ->server ->hostname }" ;
451- $ keyfile .= "\n" ;
452- }
453- if ($ entity ->server ->key_management != 'decommissioned ' ) {
454- $ keys = $ entity ->list_public_keys ($ account_name , $ hostname , false );
455- foreach ($ keys as $ key ) {
456- $ keyfile .= $ prefix .$ key ->export_serverkey_with_fixed_comment ($ entity , $ comment )."\n" ;
457- }
458- } elseif ($ comment == 1 ) {
459- $ keyfile .= "# Decommissioned server \n" ;
460- }
610+ $ keyfile = append_serveraccount_keys (
611+ $ keyfile ,
612+ $ entity ,
613+ $ prefix ,
614+ $ account_name ,
615+ $ hostname ,
616+ $ comment
617+ );
461618 break ;
462619 case 'Group ' :
463620 // Recurse!
0 commit comments