001    //$HeadURL: https://svn.wald.intevation.org/svn/deegree/base/branches/2.3_testing/src/org/deegree/tools/security/ActiveDirectoryImporter.java $
002    /*----------------------------------------------------------------------------
003     This file is part of deegree, http://deegree.org/
004     Copyright (C) 2001-2009 by:
005       Department of Geography, University of Bonn
006     and
007       lat/lon GmbH
008    
009     This library is free software; you can redistribute it and/or modify it under
010     the terms of the GNU Lesser General Public License as published by the Free
011     Software Foundation; either version 2.1 of the License, or (at your option)
012     any later version.
013     This library is distributed in the hope that it will be useful, but WITHOUT
014     ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
015     FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
016     details.
017     You should have received a copy of the GNU Lesser General Public License
018     along with this library; if not, write to the Free Software Foundation, Inc.,
019     59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
020    
021     Contact information:
022    
023     lat/lon GmbH
024     Aennchenstr. 19, 53177 Bonn
025     Germany
026     http://lat-lon.de/
027    
028     Department of Geography, University of Bonn
029     Prof. Dr. Klaus Greve
030     Postfach 1147, 53001 Bonn
031     Germany
032     http://www.geographie.uni-bonn.de/deegree/
033    
034     e-mail: info@deegree.org
035    ----------------------------------------------------------------------------*/
036    package org.deegree.tools.security;
037    
038    import java.io.FileInputStream;
039    import java.io.IOException;
040    import java.io.PrintWriter;
041    import java.io.StringWriter;
042    import java.util.ArrayList;
043    import java.util.HashMap;
044    import java.util.Hashtable;
045    import java.util.Iterator;
046    import java.util.Properties;
047    
048    import javax.naming.Context;
049    import javax.naming.NamingEnumeration;
050    import javax.naming.NamingException;
051    import javax.naming.directory.Attribute;
052    import javax.naming.directory.Attributes;
053    import javax.naming.directory.SearchControls;
054    import javax.naming.directory.SearchResult;
055    import javax.naming.ldap.Control;
056    import javax.naming.ldap.InitialLdapContext;
057    import javax.naming.ldap.LdapContext;
058    import javax.naming.ldap.PagedResultsControl;
059    import javax.naming.ldap.PagedResultsResponseControl;
060    
061    import org.deegree.framework.mail.EMailMessage;
062    import org.deegree.framework.mail.MailHelper;
063    import org.deegree.framework.mail.SendMailException;
064    import org.deegree.security.GeneralSecurityException;
065    import org.deegree.security.UnauthorizedException;
066    import org.deegree.security.drm.ManagementException;
067    import org.deegree.security.drm.SecurityAccess;
068    import org.deegree.security.drm.SecurityAccessManager;
069    import org.deegree.security.drm.SecurityHelper;
070    import org.deegree.security.drm.SecurityTransaction;
071    import org.deegree.security.drm.UnknownException;
072    import org.deegree.security.drm.model.Group;
073    import org.deegree.security.drm.model.User;
074    
075    /**
076     * This class provides the functionality to synchronize the <code>User</code> and
077     * <code>Group</code> instances stored in a <code>SecurityManager</code> with an
078     * ActiveDirectory-Server.
079     * <p>
080     * Synchronization involves four steps:
081     * <ul>
082     * <li>synchronization of groups
083     * <li>synchronization of users
084     * <li>updating of the special group "SEC_ALL" (contains all users)
085     * <li>testing of subadmin-role validity (only one role per user max)
086     * </ul>
087     * Changes are committed after all steps succeeded. If an error occurs, changes in the
088     * <code>SecurityManager</code> are undone.
089     * <p>
090     *
091     *
092     * @version $Revision: 18195 $
093     * @author <a href="mailto:schneider@lat-lon.de">Markus Schneider</a>
094     * @author <a href="mailto:poth@lat-lon.de">Andreas Poth</a>
095     * @author last edited by: $Author: mschneider $
096     *
097     * @version $Revision: 18195 $, $Date: 2009-06-18 17:55:39 +0200 (Do, 18. Jun 2009) $
098     */
099    public class ActiveDirectoryImporter {
100    
101        private SecurityAccessManager manager;
102    
103        private SecurityAccess access;
104    
105        private SecurityTransaction trans;
106    
107        private User admin;
108    
109        private Hashtable<String, String> env;
110    
111        private LdapContext ctx;
112    
113        private Properties config;
114    
115        // mail configuration
116        private static String mailSender;
117    
118        private static String mailRcpt;
119    
120        private static String mailHost;
121    
122        private static boolean mailLog;
123    
124        // number of results to fetch in one batch
125        private int pageSize = 500;
126    
127        private StringBuffer logBuffer = new StringBuffer( 1000 );
128    
129        /**
130         * Constructs a new <code>ADExporter</code> -instance.
131         * <p>
132         *
133         * @param config
134         * @throws NamingException
135         * @throws GeneralSecurityException
136         */
137        ActiveDirectoryImporter( Properties config ) throws NamingException, GeneralSecurityException {
138    
139            this.config = config;
140    
141            // retrieve mail configuration first
142            mailSender = getPropertySafe( "mailSender" );
143            mailRcpt = getPropertySafe( "mailRcpt" );
144            mailHost = getPropertySafe( "mailHost" );
145            mailLog = getPropertySafe( "mailLog" ).equals( "true" ) || getPropertySafe( "mailLog" ).equals( "yes" ) ? true
146                                                                                                                   : false;
147    
148            // get a SecurityManager (with an SQLRegistry)
149            Properties registryProperties = new Properties();
150            registryProperties.put( "driver", getPropertySafe( "sqlDriver" ) );
151            registryProperties.put( "url", getPropertySafe( "sqlLogon" ) );
152            registryProperties.put( "user", getPropertySafe( "sqlUser" ) );
153            registryProperties.put( "password", getPropertySafe( "sqlPass" ) );
154    
155            // default timeout: 20 min
156            long timeout = 1200000;
157            try {
158                timeout = Long.parseLong( getPropertySafe( "timeout" ) );
159            } catch ( NumberFormatException e ) {
160                logBuffer.append( "Specified property value for timeout invalid. " + "Defaulting to 1200 (secs)." );
161            }
162    
163            if ( !SecurityAccessManager.isInitialized() ) {
164                SecurityAccessManager.initialize( "org.deegree.security.drm.SQLRegistry", registryProperties, timeout );
165            }
166    
167            manager = SecurityAccessManager.getInstance();
168            admin = manager.getUserByName( getPropertySafe( "u3rAdminName" ) );
169            admin.authenticate( getPropertySafe( "u3rAdminPassword" ) );
170    
171            // prepare LDAP connection
172            String jndiURL = "ldap://" + getPropertySafe( "ldapHost" ) + ":389/";
173            String initialContextFactory = "com.sun.jndi.ldap.LdapCtxFactory";
174            String authenticationMode = "simple";
175            String contextReferral = "ignore";
176            env = new Hashtable<String, String>();
177            env.put( Context.INITIAL_CONTEXT_FACTORY, initialContextFactory );
178            env.put( Context.PROVIDER_URL, jndiURL );
179            env.put( Context.SECURITY_AUTHENTICATION, authenticationMode );
180            env.put( Context.SECURITY_PRINCIPAL, getPropertySafe( "ldapUser" ) );
181            env.put( Context.SECURITY_CREDENTIALS, getPropertySafe( "ldapPass" ) );
182            env.put( Context.REFERRAL, contextReferral );
183    
184            access = manager.acquireAccess( admin );
185            trans = manager.acquireTransaction( admin );
186            ctx = new InitialLdapContext( env, null );
187    
188        }
189    
190        /**
191         * Returns a configuration property. If it is not defined, an exception is thrown.
192         * <p>
193         *
194         * @param name
195         * @return a configuration property. If it is not defined, an exception is thrown.
196         */
197        private String getPropertySafe( String name ) {
198    
199            String value = config.getProperty( name );
200            if ( value == null ) {
201                throw new RuntimeException( "Configuration does not define needed property '" + name + "'." );
202            }
203            return value;
204        }
205    
206        /**
207         * Synchronizes the AD's group objects with the SecurityManager's group objects.
208         * <p>
209         *
210         * @return the mapping of the String to the Groups
211         * @throws NamingException
212         * @throws IOException
213         * @throws GeneralSecurityException
214         * @throws UnauthorizedException
215         */
216        HashMap<String, Group> synchronizeGroups()
217                                throws NamingException, IOException, UnauthorizedException, GeneralSecurityException {
218            // keys are names (Strings), values are Group-objects
219            HashMap<String, Group> groupMap = new HashMap<String, Group>( 20 );
220            // keys are distinguishedNames (Strings), values are Group-objects
221            HashMap<String, Group> groupMap2 = new HashMap<String, Group>( 20 );
222            // keys are names (Strings), values are NamingEnumeration-objects
223            HashMap<String, NamingEnumeration<?>> memberOfMap = new HashMap<String, NamingEnumeration<?>>( 20 );
224    
225            byte[] cookie = null;
226    
227            // specify the ids of the attributes to return
228            String[] attrIDs = { "distinguishedName", getPropertySafe( "groupName" ), getPropertySafe( "groupTitle" ),
229                                getPropertySafe( "groupMemberOf" ) };
230    
231            // set SearchControls
232            SearchControls ctls = new SearchControls();
233            ctls.setReturningAttributes( attrIDs );
234            ctls.setSearchScope( SearchControls.SUBTREE_SCOPE );
235    
236            // specify the search filter to match
237            // ask for objects that have the attribute "objectCategory" =
238            // "CN=Group*"
239            String filter = getPropertySafe( "groupFilter" );
240            String context = getPropertySafe( "groupContext" );
241    
242            // create initial PagedResultsControl
243            ctx.setRequestControls( new Control[] { new PagedResultsControl( pageSize, false ) } );
244    
245            // phase 1: make sure that all groups from AD are present in the
246            // SecurityManager
247            // (register them to the SecurityManager if necessary)
248            do {
249                // perform the search
250                NamingEnumeration<?> answer = ctx.search( context, filter, ctls );
251    
252                // process results of the last batch
253                while ( answer.hasMoreElements() ) {
254                    SearchResult result = (SearchResult) answer.nextElement();
255                    Attributes atts = result.getAttributes();
256                    String distinguishedName = (String) atts.get( "distinguishedName" ).get();
257                    String name = (String) atts.get( getPropertySafe( "groupName" ) ).get();
258                    String title = (String) atts.get( getPropertySafe( "groupTitle" ) ).get();
259    
260                    // check if group is already registered
261                    Group group = null;
262                    try {
263                        group = access.getGroupByName( name );
264                    } catch ( UnknownException e ) {
265                        // no -> register group
266                        logBuffer.append( "Registering group: " + name + "\n" );
267                        group = trans.registerGroup( name, title );
268                    }
269                    groupMap.put( name, group );
270                    groupMap2.put( distinguishedName, group );
271                    if ( atts.get( getPropertySafe( "groupMemberOf" ) ) != null ) {
272                        memberOfMap.put( name, atts.get( getPropertySafe( "groupMemberOf" ) ).getAll() );
273                    }
274                }
275    
276                // examine the paged results control response
277                Control[] controls = ctx.getResponseControls();
278                if ( controls != null ) {
279                    for ( int i = 0; i < controls.length; i++ ) {
280                        if ( controls[i] instanceof PagedResultsResponseControl ) {
281                            PagedResultsResponseControl prrc = (PagedResultsResponseControl) controls[i];
282                            // total = prrc.getResultSize();
283                            cookie = prrc.getCookie();
284                        }
285                    }
286                }
287    
288                if ( cookie != null ) {
289                    // re-activate paged results
290                    ctx.setRequestControls( new Control[] { new PagedResultsControl( pageSize, cookie, Control.CRITICAL ) } );
291                }
292            } while ( cookie != null );
293    
294            // phase 2: make sure that all groups from the SecurityManager are known
295            // to the AD
296            // (deregister them from the SecurityManager if necessary)
297            Group[] sMGroups = access.getAllGroups();
298            for ( int i = 0; i < sMGroups.length; i++ ) {
299                if ( groupMap.get( sMGroups[i].getName() ) == null && sMGroups[i].getID() != Group.ID_SEC_ADMIN
300                     && !( sMGroups[i].getName().equals( "SEC_ALL" ) ) ) {
301                    logBuffer.append( "Deregistering group: " + sMGroups[i].getName() + "\n" );
302                    trans.deregisterGroup( sMGroups[i] );
303                }
304            }
305    
306            // phase 3: set the membership-relations between the groups
307            Iterator<String> it = groupMap.keySet().iterator();
308            while ( it.hasNext() ) {
309                String name = it.next();
310                Group group = groupMap.get( name );
311                NamingEnumeration<?> memberOf = memberOfMap.get( name );
312                ArrayList<Group> memberOfList = new ArrayList<Group>( 5 );
313    
314                if ( memberOf != null ) {
315                    while ( memberOf.hasMoreElements() ) {
316                        String memberGroupName = (String) memberOf.nextElement();
317                        Group memberGroup = groupMap2.get( memberGroupName );
318                        if ( memberGroup != null ) {
319                            memberOfList.add( memberGroup );
320                        } else {
321                            logBuffer.append( "Group " + name + " is member of unknown group " + memberGroupName
322                                              + ". Membership ignored.\n" );
323                        }
324                    }
325                }
326                Group[] newGroups = memberOfList.toArray( new Group[memberOfList.size()] );
327                trans.setGroupsForGroup( group, newGroups );
328            }
329            return groupMap2;
330        }
331    
332        /**
333         * Synchronizes the AD's user objects with the SecurityManager's user objects.
334         * <p>
335         *
336         * @param groups
337         *
338         * @throws NamingException
339         * @throws IOException
340         * @throws GeneralSecurityException
341         * @throws UnauthorizedException
342         *
343         */
344        void synchronizeUsers( HashMap<String, Group> groups )
345                                throws NamingException, IOException, UnauthorizedException, GeneralSecurityException {
346            // keys are names (Strings), values are User-objects
347            HashMap<String, User> userMap = new HashMap<String, User>( 20 );
348            // keys are names (Strings), values are NamingEnumeration-objects
349            HashMap<String, NamingEnumeration<?>> memberOfMap = new HashMap<String, NamingEnumeration<?>>( 20 );
350    
351            byte[] cookie = null;
352    
353            // specify the ids of the attributes to return
354            String[] attrIDs = { getPropertySafe( "userName" ), getPropertySafe( "userTitle" ),
355                                getPropertySafe( "userFirstName" ), getPropertySafe( "userLastName" ),
356                                getPropertySafe( "userMail" ), getPropertySafe( "userMemberOf" ) };
357    
358            SearchControls ctls = new SearchControls();
359            ctls.setReturningAttributes( attrIDs );
360            ctls.setSearchScope( SearchControls.SUBTREE_SCOPE );
361    
362            // specify the search filter to match
363            String filter = getPropertySafe( "userFilter" );
364            String context = getPropertySafe( "userContext" );
365    
366            // create initial PagedResultsControl
367            ctx.setRequestControls( new Control[] { new PagedResultsControl( pageSize, false ) } );
368    
369            // phase 1: make sure that all users from AD are present in the
370            // SecurityManager
371            // (register them to the SecurityManager if necessary)
372            do {
373                // perform the search
374                NamingEnumeration<?> answer = ctx.search( context, filter, ctls );
375    
376                // process results of the last batch
377                while ( answer.hasMoreElements() ) {
378                    SearchResult result = (SearchResult) answer.nextElement();
379    
380                    Attributes atts = result.getAttributes();
381                    Attribute nameAtt = atts.get( getPropertySafe( "userName" ) );
382                    // Attribute titleAtt = atts.get( getPropertySafe( "userTitle" ) );
383                    Attribute firstNameAtt = atts.get( getPropertySafe( "userFirstName" ) );
384                    Attribute lastNameAtt = atts.get( getPropertySafe( "userLastName" ) );
385                    Attribute mailAtt = atts.get( getPropertySafe( "userMail" ) );
386                    Attribute memberOfAtt = atts.get( getPropertySafe( "userMemberOf" ) );
387    
388                    String name = (String) nameAtt.get();
389                    // String title = titleAtt != null ? (String) titleAtt.get() : "";
390                    String firstName = firstNameAtt != null ? (String) firstNameAtt.get() : "" + "";
391                    String lastName = lastNameAtt != null ? (String) lastNameAtt.get() : "" + "";
392                    String mail = mailAtt != null ? (String) mailAtt.get() : "";
393    
394                    // check if user is already registered
395                    User user = null;
396                    try {
397                        user = access.getUserByName( name );
398                    } catch ( UnknownException e ) {
399                        // no -> register user
400                        logBuffer.append( "Registering user: " + name + "\n" );
401                        user = trans.registerUser( name, null, lastName, firstName, mail );
402                    }
403                    userMap.put( name, user );
404    
405                    if ( memberOfAtt != null ) {
406                        memberOfMap.put( name, memberOfAtt.getAll() );
407                    }
408                }
409    
410                // examine the paged results control response
411                Control[] controls = ctx.getResponseControls();
412                if ( controls != null ) {
413                    for ( int i = 0; i < controls.length; i++ ) {
414                        if ( controls[i] instanceof PagedResultsResponseControl ) {
415                            PagedResultsResponseControl prrc = (PagedResultsResponseControl) controls[i];
416                            // total = prrc.getResultSize();
417                            cookie = prrc.getCookie();
418                        }
419                    }
420                }
421    
422                if ( cookie != null ) {
423                    // re-activate paged results
424                    ctx.setRequestControls( new Control[] { new PagedResultsControl( pageSize, cookie, Control.CRITICAL ) } );
425                }
426            } while ( cookie != null );
427    
428            // phase 2: make sure that all users from the SecurityManager are known
429            // to the AD
430            // (deregister them from the SecurityManager if necessary)
431            User[] sMUsers = access.getAllUsers();
432            for ( int i = 0; i < sMUsers.length; i++ ) {
433                if ( userMap.get( sMUsers[i].getName() ) == null && sMUsers[i].getID() != User.ID_SEC_ADMIN ) {
434                    logBuffer.append( "Deregistering user: " + sMUsers[i].getName() + "\n" );
435                    trans.deregisterUser( sMUsers[i] );
436                }
437            }
438    
439            // phase 3: set the membership-relations between the groups and the
440            // users
441            Iterator<String> it = userMap.keySet().iterator();
442            while ( it.hasNext() ) {
443                String name = it.next();
444                User user = userMap.get( name );
445                NamingEnumeration<?> memberOf = memberOfMap.get( name );
446                ArrayList<Group> memberOfList = new ArrayList<Group>( 5 );
447                if ( memberOf != null ) {
448                    while ( memberOf.hasMoreElements() ) {
449                        String memberGroupName = (String) memberOf.nextElement();
450                        Group memberGroup = groups.get( memberGroupName );
451                        if ( memberGroup != null ) {
452                            memberOfList.add( memberGroup );
453                        } else {
454                            logBuffer.append( "User " + name + " is member of unknown group " + memberGroupName
455                                              + ". Membership ignored.\n" );
456                        }
457                    }
458                }
459                Group[] newGroups = memberOfList.toArray( new Group[memberOfList.size()] );
460                trans.setGroupsForUser( user, newGroups );
461            }
462    
463        }
464    
465        /**
466         * Updates the special group "SEC_ALL" (contains all users).
467         * <p>
468         *
469         * @throws GeneralSecurityException
470         *
471         */
472        void updateSecAll()
473                                throws GeneralSecurityException {
474            Group secAll = null;
475    
476            // phase1: make sure that group "SEC_ALL" exists
477            // (register it if necessary)
478            try {
479                secAll = access.getGroupByName( "SEC_ALL" );
480            } catch ( UnknownException e ) {
481                secAll = trans.registerGroup( "SEC_ALL", "SEC_ALL" );
482            }
483    
484            // phase2: set all users to be members of this group
485            User[] allUsers = access.getAllUsers();
486            trans.setUsersInGroup( secAll, allUsers );
487    
488        }
489    
490        /**
491         * Checks subadmin-role validity (each user one role max).
492         * <p>
493         *
494         * @throws ManagementException
495         * @throws GeneralSecurityException
496         */
497        void checkSubadminRoleValidity()
498                                throws ManagementException, GeneralSecurityException {
499            SecurityHelper.checkSubadminRoleValidity( access );
500        }
501    
502        /**
503         * Aborts the synchronization process and undoes all changes.
504         */
505        public void abortChanges() {
506            if ( manager != null && trans != null ) {
507                try {
508                    manager.abortTransaction( trans );
509                } catch ( GeneralSecurityException e ) {
510                    e.printStackTrace();
511                }
512            }
513            if ( ctx != null ) {
514                try {
515                    ctx.close();
516                } catch ( NamingException e ) {
517                    e.printStackTrace();
518                }
519            }
520        }
521    
522        /**
523         * Ends the synchronization process and commits all changes.
524         */
525        public void commitChanges() {
526            if ( manager != null && trans != null ) {
527                try {
528                    manager.commitTransaction( trans );
529                } catch ( GeneralSecurityException e ) {
530                    e.printStackTrace();
531                }
532            }
533            if ( ctx != null ) {
534                try {
535                    ctx.close();
536                } catch ( NamingException e ) {
537                    e.printStackTrace();
538                }
539            }
540        }
541    
542        /**
543         * Sends an eMail to inform the admin that something went wrong.
544         * <p>
545         * NOTE: This is static, because it must be usable even when the construction of the ADExporter
546         * failed.
547         * <p>
548         *
549         * @param e
550         */
551        public static void sendError( Exception e ) {
552    
553            try {
554                String mailMessage = "Beim Synchronisieren des ActiveDirectory mit der HUIS-"
555                                     + "Sicherheitsdatenbank ist ein Fehler aufgetreten.\n"
556                                     + "Die Synchronisierung wurde NICHT durchgeführt, der letzte "
557                                     + "Stand wurde wiederhergestellt.\n";
558                StringWriter sw = new StringWriter();
559                PrintWriter writer = new PrintWriter( sw );
560                e.printStackTrace( writer );
561                mailMessage += "\n\nDie Java-Fehlermeldung lautet:\n" + sw.getBuffer();
562    
563                mailMessage += "\n\nMit freundlichem Gruss,\nIhr ADExporter";
564                MailHelper.createAndSendMail(
565                                              new EMailMessage( mailSender, mailRcpt, "Fehler im ADExporter", mailMessage ),
566                                              mailHost );
567            } catch ( SendMailException ex ) {
568                ex.printStackTrace();
569            }
570    
571        }
572    
573        /**
574         * Sends an eMail with a log of the transaction.
575         * <p>
576         */
577        public void sendLog() {
578    
579            try {
580                String mailMessage = "Die Synchronisierung der HUIS-Sicherheitsdatenbank mit "
581                                     + "dem ActiveDirectory wurde erfolgreich durchgeführt:\n\n";
582                if ( logBuffer.length() == 0 ) {
583                    mailMessage += "Keine Änderungen.";
584                } else {
585                    mailMessage += logBuffer.toString();
586                }
587                mailMessage += "\n\nMit freundlichem Gruss,\nIhr ADExporter";
588                EMailMessage emm = new EMailMessage( mailSender, mailRcpt, "ActiveDirectory Sychronisierung durchgeführt",
589                                                     mailMessage );
590                MailHelper.createAndSendMail( emm, mailHost );
591            } catch ( SendMailException ex ) {
592                ex.printStackTrace();
593            }
594    
595        }
596    
597        /**
598         * @param args
599         * @throws Exception
600         */
601        public static void main( String[] args )
602                                throws Exception {
603    
604            if ( args.length != 1 ) {
605                System.out.println( "USAGE: ADExporter configfile" );
606                System.exit( 0 );
607            }
608    
609            long begin = System.currentTimeMillis();
610            System.out.println( "Beginning synchronisation..." );
611    
612            ActiveDirectoryImporter exporter = null;
613            try {
614                Properties config = new Properties();
615                config.load( new FileInputStream( args[0] ) );
616                exporter = new ActiveDirectoryImporter( config );
617    
618                HashMap<String, Group> groups = exporter.synchronizeGroups();
619                exporter.synchronizeUsers( groups );
620                exporter.updateSecAll();
621                exporter.checkSubadminRoleValidity();
622                exporter.commitChanges();
623            } catch ( Exception e ) {
624                if ( exporter != null ) {
625                    exporter.abortChanges();
626                }
627                sendError( e );
628                System.err.println( "Synchronisation has been aborted. Error message: " );
629                e.printStackTrace();
630                System.exit( 0 );
631            }
632    
633            if ( mailLog ) {
634                exporter.sendLog();
635            }
636    
637            System.out.println( "Synchronisation took " + ( System.currentTimeMillis() - begin ) + " milliseconds." );
638    
639            System.exit( 0 );
640        }
641    }