NT Administration with Perl

Dave Roth

Let's say you administer a network of thousands of Win32 machines, and one day you decide to synchronize all of their clocks. Logging onto each workstation as an administrator so that you can run NET TIME will take days. Thanks to Win32 Perl, it'll take you less than an hour, with the help of its modules that support an armada of administrative functions. In this column, we'll explore the features found in two of those modules: Win32::NetAdmin and Win32::AdminMisc.

You want each of your thousands of workstations to run the command NET TIME \\SERVER /SET /Y, which sets the local machine's time and date to match the time on the machine named \\SERVER. The /SET and /Y flags set the time and automatically answer "yes" to any questions asked by the system. Luckily, you long ago set the machines' schedule services (see sidebar, next page) to log on to \\SERVER under an administrative account. This allows you to set a scheduled job to run as if the administrator logged on and ran it from the console. All you need to do is to add a scheduled job to each machine. Consider the program below, which uses one function from each module:

1. use Win32::AdminMisc;
2. use Win32::NetAdmin;
4. srand(time);
5. $Domain = "SOUTH_PARK";
6. $Server = "\\\\ServerName";
7. Win32::NetAdmin::GetServers($Server, $Domain,
                                          SV_TYPE_ALL, \@List);
8. foreach $Machine (@List){ 
9.     $Time = sprintf("3:%02d am", int(rand(60)));
10.    if (Win32::AdminMisc::ScheduleAdd($Machine,
11.               $Time, 
12.               0, 
13.               SUNDAY, 
15.               "net time \\\\server /s /y")) { 
16.      print "Job added to $Machine at $Time every Sunday.\n";
17.    } else { 
18.      print "Job not added to $Machine.\n"; 
19.    } 
20. }

We retrieve a list of all of the computer names (by specifying the SV_TYPE_ALL flag) in the 'SOUTH_PARK' domain. The script requests the computer \\ServerName to generate this list on our behalf. You could request the list from your Primary Domain Controller - however, if your PDC is busy with other requests and services you could request any machine (PDC, BDC, server, or workstation) to generate it.( A primary domain controller (PDC) is a special NT server that contains the master database of user accounts, user groups, computer accounts, and domain trust accounts. A PDC will replicate to and update from a BDC (backup domain controller) every so often. Typically, the PDC has the most updated database.) If $Server is empty, GetServers() requests the list from the computer running the program. Once that a list has been retrieved, we walk through it while line 9 picks a random time between 3:00 am and 3:59 am. A job is submitted to $Machine in lines 10 through 15. These lines show how you can schedule a job on a remote machine, specifying the time, day of week, day of month, special flags, and what command to execute.

This is just a sampling of what you can do with the NetAdmin and AdminMisc modules. NetAdmin provides the basic WinNT administrator functions: adding and removing user accounts, adding and removing users from groups, creating and deleting groups, and so on. There are, however, some limitations of NetAdmin, and that's why I wrote AdminMisc, described later. AdminMisc wasn't designed to replace NetAdmin so much as to complement it with extra functions that let you edit the full spectrum of user account information, rename a user, set a user's password, retrieve the time of day, and more.

The NetAdmin Module

Win32::NetAdmin comes with the ActiveState version of Perl and the core distribution's Win32 standard library. I've used NetAdmin in CGI scripts that manage accounts, and in programs that run perform maintenance on multiple computers. NT includes tools that handle most administrative functions - but most are GUI based. If you've ever had to enter hundreds of users with the User Manager you know why NetAdmin is a must. I'll describe some common NetAdmin tasks now.

Scheduling like an Admin

The Windows NT schedule service is the equivalent of the Unix cron daemon - just not as flexible. To set up the schedule service so it runs under an administrative account, you need to first choose a user ID that belongs to the Administrators group. Next, you will need to log on to your workstation (or server) as an administrator, and open the Services applet in the control panel. Now select the "schedule" service and hit the STARTUP button. From here you should specify the "startup type" as automatic. Select the option "This Account:" from the box section of the window labeled "Log On As:" and type in the name of the administrative account you created, prepended with the domain name (such as MYDOMAIN\ScheduleService). You’ll need to put in the password and confirm it. If you’ve done everything correctly you can hit the OK button and then start the service. The service automatically start when you next reboot the machine.

Managing user accounts. Removing a user account with NetAdmin is easy: Win32::NetAdmin::UserDelete($Server, $User). $Server contains the name of the machine to perform the task (usually a PDC or BDC). If it's an empty string, the computer running the program executes the deletion. Like most of the functions in these modules, UserDelete() returns 1 on success and 0 on failure.Adding users is a bit more involved:

1. Win32::NetAdmin::UserCreate(
2.    $Domain,                       # Domain
3.    $User,                         # Userid 
4.    $Password,                     # Password 
5.    $PasswordAge,                  # Password Age 
6.    USER_PRIV_USER,                # Privileges 
7.    $HomeDir,                      # Home directory 
8.    $Comment,                      # Comment 
10.   "c:\\scripts\\$User.bat"       # Log on script 

$Domain can be a domain name, a server, or an empty string (which uses the default domain). $User, $Password, $HomeDir, and $Comment are self-explanatory. $PasswordAge is ignored, and the privilege should always be the constant USER_PRIV_USER. For the flags on line 9, you must specify at least UF_NORMAL_ACCOUNT and UF_SCRIPT. There are four other flags that you can use (by logically OR'ing them together):

UF_ACCOUNTDISABLE        Disable the account.
UF_PASSWD_NOTREQD        No password is required.
UF_PASSWD_CANT_CHANGE    User can not change the password.
UF_DONT_EXPIRE_PASSWD    The password never expires.

If you want to get an entire list of all of your users, you can use: Win32::NetAdmin::GetUsers($Server, FILTER_NORMAL_ACCOUNT, \@Users). Upon success, this fills @Users with a list of user account names. Once again, $Server is the name of the machine that executes the function on our behalf, and an empty string means the computer you're on now.

Managing groups. Once you've populated your user database with accounts, you'll want to start manipulating which groups users belong to. Group membership is a nifty feature of NT, making it easy to assign permissions to files and directories as well as printers, remote dial-in modems, and other resources.

If you want to create a new group you have to first decide what kind of group you want: global (Win32::NetAdmin::GroupCreate($Server, $GroupName, $Comment)) or local (Win32::NetAdmin::LocalGroupCreate($Server, $GroupName, $Comment)).(A full definition of global versus local groups is beyond the scope of this article. Suffice it to say that a global group consists of user accounts and local groups from your domain as well as other domains that you trust, while a local group contains only accounts from your local domain. Local groups can be seen only by the local domain; global groups can be seen by other domains. ) The calls accept the same $server name convention we've seen before. The $GroupName is the name of the group you are going to create (such as "Internet Users"). An example:

                             "Internet Users",
                             "Those users who can access the Internet")

Now that you have some groups, you can add users to them with Win32::NetAdmin::GroupAddUsers($Server, $GroupName, $User) or Win32::NetAdmin::LocalGroupAddUsers($Server, $GroupName, $User). The variables are the same as when you create a group - with the exception that $User is the name of the user account to be added. There's a trick that isn't documented: If $User is a reference to an array, these functions add all the users in the array. The following code thus adds four users to the "administrators" local group:

1. @Users = ('Stan', 'Kyle', 'Kenny', 'Cartman'); 
2. Win32::NetAdmin::LocalGroupAddUsers('',

Removing users from groups is straightforward:

Win32::NetAdmin::GroupDelUsers($Server, $GroupName, $User);



Likewise, you can provide an array reference when deleting users:


Checking for group membership. To determine whether a user is in a particular group, you need to remember to check both local and global groups:

sub IsMember {
   my ($Server, $Group, $User) = @_;
   return (Win32::GroupIsMember($Server, 
                               $User) ||

If this subroutine were invoked as IsMember("SOUTH_PARK", "Children", "Cartman") it would return 1 if there was a group (either local or global) called "Children" containing Cartman.

Retrieve a domain controller. There may come a time when you need to know the primary domain controller for your domain. You can retrieve the name of this computer with Win32::NetAdmin::GetDomainController($Server, $Domain, $Name). The $Domain argument specifies which domain to consider; if it's empty, the primary domain of $Server is used. If the function is successful, $Name will be set to the name of the PDC and the function will return a 1, as per usual.

The AdminMisc Module

Some time ago I wrote a module called Win32::AdminMisc that provides functions not found in any of the standard Win32 extensions. I will describe some of its functionality here; more information is at:

Modifying user accounts. NetAdmin allows an administrator to modify attributes of a user account. You can change the home directory, flags, comments, and the log on script. However, it's limited; you can't, say, change the user's full name. The AdminMisc module lets you set a wealth of attributes, including:

A call to the UserGetMiscAttributes() function returns a hash of values constituting the user's account information:

if (Win32::AdminMisc::UserGetMiscAttributes('SOUTH_PARK',
                                            \%Hash)) {
     map {print "$_=$Hash{$_}\n";} sort(keys(%Hash)); 

This displays:

1.  USER_ACCT_EXPIRES=4294967295
5.  USER_COMMENT=just a test account
7.  USER_FLAGS=66049
8.  USER_FULL_NAME=Eric Cartman
9.  USER_HOME_DIR=\\server1\users\cartman
11. USER_LAST_LOGOFF=876103123
12. USER_LAST_LOGON=876092928
15. USER_MAX_STORAGE=4294967295
16. USER_NAME=cartman
24. USER_PROFILE=c:\users\profiles\cartman
25. USER_SCRIPT_PATH=\\server1\scripts\logon.bat
27. USER_USER_ID=1002

Some of this information is read-only. The most important attributes that can't be changed with UserSetMiscAttributes() are the username (line 16) and the password (line 19). These can be altered with RenameUser() and SetPassword() - just pass in some named parameters (with =>):

				  USER_COMMENT => "CheesyPoofs Rule!",
                  USER_PROFILE => "c:\\temp\\cartman.usr");

The USER_LAST_LOGON and USER_LAST_LOGOFF attributes are in UNIX time format - the number of seconds since January 1, 1970 - so you can display them with localtime():

print "Last logged on: ", localtime($Hash{USER_LAST_LOGON}), "\n";

Renaming accounts. One of the most popular functions in AdminMisc is RenameUser(), which lets you rename an account.(Many people want to know why I don't let you set the username with UserSetMiscAttributes(). The Win32 API prohibits altering the username and password with the same function and structure used for UserSetMiscAttributes(). I could have modified the code to accommodate it but I felt that using a RenameUser() function is simpler and more intuitive.) If you wanted to rename the user account Eric to Cartman it's as easy as Win32::AdminMisc::RenameUser("SOUTH_PARK", "Eric", "Cartman"). Wouldn't be great if more tasks were that easy? Resetting a password is.

Resetting a user's password. To reset a user's password, use SetPassword(). You need to be logged on as an administrator or account operator, and then invoke it as follows:

Win32::AdminMisc::SetPassword($Server, "Cartman", "CheesyPoofs");

$Server can be any domain name, or even a computer name if you prepend it with double slashes: \\server or //server.

Log on as another user (impersonation). I typically launch lots of applications when I develop code. I might have a C compiler, the user manager, the server manager, about five DOS emulations, a text editor, a mail program, and a web browser. I need all this clutter - but it becomes a burden when trying to debug an application that fails only when a particular user runs it. Instead of shutting down all my applications, logging off, and then logging in as the user, I use a process known as impersonation, shown below.

$Domain = "SOUTH_PARK"; 
if (Win32::AdminMisc::LogonAsUser($Domain, "Cartman",
                                  "CheesyPoofs")) {
print "Logged on as: ", Win32::AdminMisc::GetLogonName(),"\n";

The LogonAsUser() function logs on as the troubled user. This changes the security context under which the Perl script runs - even though I am an administrator for my domain and have superuser permissions, the process running the Perl script will have limited access - it can do only what the user can do. The script then confirms that it's running as the desired user by printing GetLogonName().(You could use Win32::LoginName(), but it seems not to work correctly with impersonated user accounts. For example, if you use LogonAsUser() to log on as administrator, Win32::LoginName() reports "administrator" - which is correct - but if you then use LogonAsUser() again to log on as, say, Joel, Win32::LoginName() still reports "administrator." Win32::AdminMisc::GetLogonName() reports, correctly, "Joel".) If the script tries to open a file or directory with inadequate permissions, access will be denied. This allows me to test the script as if it was running on this user's machine while he is logged on. If you've already thought about how groovy it would be to do this in a separate process, you're one step ahead of me.

Spawning a process as another user. What if you need to start another program (like Microsoft Word or a DOS process) under another user's context? It's not as easy as you would think. When you impersonate a user, the current running process (the Perl script) is placed under the user's context. If the Perl script spawns another process, it runs under the original user's context, not the impersonated context. There is a function to get around this situation called CreateProcessAsUser(), but it's a bit confusing. Consider this code:

1. use Win32::AdminMisc;
2. if (Win32::AdminMisc::LogonAsUser($Server, "Cartman",
                                     "CheesyPoofs")) {
3.     $User = Win32::AdminMisc::GetLogonName();
4.     $Result = Win32::AdminMisc::CreateProcessAsUser(
5.                        "cmd.exe",
6.                        "Flags", CREATE_NEW_CONSOLE, 
7.                        "XSize", 640, 
8.                        "YSize", 400, 
9.                        "X", 200, 
10.                       "Y", 175, 
11.                       "XBuffer", 80, 
12.                       "YBuffer", 175, 
13.                       "Show", SW_MINIMIZE, 
14.                       "Title", "$User's process", 
15.                       "Inherit", 1, 
16.                       "Fill", BACKGROUND_BLUE | 
17.                               FOREGROUND_RED | 
18.                               FOREGROUND_BLUE | 
19.                               FOREGROUND_INTENSITY |
20.                               FOREGROUND_GREEN, 
21.             ); 
22. }

Let's take a look at this mess. First we log on as Cartman (line 2) and retrieve the current user's ID in line 3, which should be cartman. Then a DOS process is launched, running under Cartman's user context (lines 4-22). Lines 6 through 20 aren't necessary, but illustrate some interesting options. In line 6 we pass the string 'Flags' followed by the CREATE_NEW_CONSOLE flag; this creates the process in a new DOS box. The XSize and YSize attributes specify the size (in pixels) of the new DOS process.X and Y tell where (also in pixels) to place the new process window, offset from the upper left of the screen. XBuffer and YBuffer indicate how many characters to buffer (great for providing a scrollable capture buffer). Line 13 makes the new window start up minimized. Title sets the contents of the title bar of the new window. Inherit specifies that open filehandles (such as STDIN, STDOUT, and STDERR) will be inherited by the new process (quite handy if you associate a socket with your STDIN/STDOUT). Finally, the Fill option sets the color of the foreground (the text) and the background of the new process window - in this case the background is blue and the foreground is bright white (a combination of red, green, and blue).

Now let's look at two more useful functions in AdminMisc: GetHostAddress() and GetHostName(). These are similar to Perl's gethostbyname() and gethostbyaddr() but are a bit simpler to use. You pass in either an IP address string (e.g. or a DNS name (e.g. and the result is its counterpart.

$IP = Win32::AdminMisc::GetHostAddress("");
$DNSname = Win32::AdminMisc::GetHostName("");

AdminMisc caches the results of both functions, remembering previously resolved addresses. You can resize the cache with Win32::AdminMisc::DNSCacheSize(2000), which resets the cache and erases its current contents. This uses more memory, but for typical query patterns it saves time. Keep in mind that if you hold too many cached entries it might take longer to look up the query in the cache than to resolve the address via DNS again.

Discovering your drives. Suppose that you are writing a script that scans your drives so you can delete all temporary files. The biggest problem is discovering how many drives you have and whether they're networked disks, CD-ROMs, or floppies (which you don't want to scan). This is where the GetDrives() and DriveType() functions come in handy. The following code generates a list of available disks:

@Drives = Win32::AdminMisc::GetDrives(); 
foreach $Drive (sort(@Drives)) {
$Type = Win32::AdminMisc::GetDriveType($Drive);
($Total, $Free) = Win32::AdminMisc::GetDriveSpace($Drive);
    if ($Type == DRIVE_FIXED) {
	$Type = "hard drive";
	} elsif ($Type == DRIVE_REMOVABLE) {
	$Type = "removable (floppy) drive";
	} elsif ($Type == DRIVE_REMOTE) {
	$Type = "network drive";
	} elsif ($Type == DRIVE_CDROM) {
	$Type = "CDROM drive";
	} elsif ($Type == DRIVE_RAMDISK) {
	$Type = "RAM disk";
	} else {
	$Type = "disk type $Type";
    print "The $Drive is a $Type and has a size of $Total
	         bytes with $Free bytes available.\n";

Here we use GetDrives() without any parameters, which returns all the drives attached to the machine. Instead, you could have passed in a drive type constant which would have selected only drives of that type, such as:

@CDRomDrives = Win32::AdminMisc::GetDrives(DRIVE_CDROM)

Who can manage accounts and groups?

Any script that manipulates accounts or groups must be run from an account that is member of either the Administrators or Account Operators group. If the script manipulates an administrator - adding or removing users from the Administrators group - then it must be run from an account with administrator privileges.

All drives that are specified or returned are represented as the root directory form of the drive, e.g. A:\ or C:\ or E:\ It is very important that you use this format. Even if you use a UNC (Universal Naming Convention) such as: \\servername\share\ make sure you specify a terminating backslash (just as if it were a root directory).

You can also use these functions to discover specific information about your drives. Let's say you're running a script that takes inventory of all your machines and you need to know how many cylinders and tracks your drives have. You can retrieve the sectors per cluster, bytes per sector, free clusters, and number of clusters on the drive with:

 $TotalClusters) = GetDriveGeometry($Drive)

Retrieving processor, process/thread, and memory information. Perhaps in addition to information about your drives, you'd like to know more about your computer's configuration: the number and types of processors, total and available memory, and the version of Windows you're running. AdminMisc provides three functions to help:

if (%Data = Win32::AdminMisc::GetProcessorInfo()){ 
    print "Memory Information:\n";
	print "\tNumber of processors: $Data{ProcessorNum}\n";
	print "\tType of processor: $Data{ProcessorType}\n";
	print "\tProcessor level: $Data{ProcessorLevel}\n";
	print "\tProcessor revision: $Data{ProcessorRevision}\n";
	print "\tPage size: $Data{PageSize}\n"; 
if (%Data = Win32::AdminMisc::GetMemoryInfo()){ 
    print "\nMemory Information:\n";
	print "\tMemory available: $Data{RAMAvail}\n";
	print "\tMemory total: $Data{RAMTotal}\n";
    print "\tVirtual mem avail: $Data{VirtAvail}\n";
	print "\tVirtual mem total: $Data{VirtTotal}\n";
	print "\tPage mem available: $Data{PageAvail}\n";
	print "\tPage mem total: $Data{PageTotal}\n";
	print "\tCurrent memory load: $Data{Load}\n"; 
if (%Data = Win32::AdminMisc::GetWinVersion()){ 
    print "\nWindows Information:\n";
	print "\tPlatform: $Data{Platform}\n";
	print "\tVersion: $Data{Major}.$Data{Minor}\n";
	print "\tBuild: $Data{Build}\n";
	print "\tService Pack version: $Data{CSD}\n"; 

The code is self-explanatory: functions GetProcessorInfo(), GetMemoryInfo(), and GetWinVersion() all return hashes of information about the machine or operating system.

Reading and writing .ini files. As you might be aware, Microsoft wants to migrate all those good old INI files into the registry.(For Windows novices: An INI file is a configuration or initialization file, typically with a file extension of .ini. The file is broken into sections; each section is a list of keys and values. The registry is a centralized database of values maintained by Windows. Software developers are encouraged to store configuration information in this registry instead of files. A nice idea, but sometimes too limiting because it doesn't allow for things like multiple configuration files in different directories.) Even though you'd like to oblige, it's not worth your time to load and exercise the registry extension just to get some configuration information. Or perhaps you find yourself needing to modify legacy applications that use INI files. You'll want to use ReadINI() and WriteINI(); just be aware that if you reference the c:/winnt/win.ini it might not be the same as plain old win.ini. Windows NT maps win.ini to two places: the physical win.ini file in the %SystemRoot% directory, and a key in the registry: HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Win-
. So whenever you change win.ini via these functions, NT modifies the registry.

If you call ReadINI() without specifying a section, a list of all section names will be returned. Likewise, if you call the function with a section but no key, a list of all the keys in the specified section will be returned. Here's an example that dumps the full contents of file.ini:

foreach $Section (sort(
                                         0 ))) {
	print "[$Section]\n";
	foreach $Key (sort( Win32::AdminMisc::ReadINI(
                                         0 ))) {
        $Value = Win32::AdminMisc::ReadINI(
		print "$Key=$Value\n"; 

The WriteINI() function works like ReadINI(). If no section name is provided, all sections are deleted - the file is cleaned out. If a section name is provided without a key, all keys in that section are deleted. If a key but no value is specified, only that key is deleted.

if ( Win32::AdminMisc::WriteINI("c:/temp/file.ini",   
                                "section 1", 
                                "")) { 
                               "section 1",
                               "Key's Value");

This removes all entries for section 1 in the INI file and then adds the following section:

[section 1]
KeyName=Key's Value

If you don't specify a path to the INI file - that is, if you give only the file name - Windows searches for it by looking in the current directory and then in your path. If all else fails it looks in the %SystemRoot% and %SystemRoot%\System32 directories.

Exiting windows (or forcing users to log off). Let's say you're using Perl to install some software, but you need the machine to shut down for a driver to take effect. Or maybe you're performing some server maintenance that requires all users to log out. Both are made possible by Win32::AdminMisc::ExitWindows(). This function takes a flag specifying how to exit:

EWX_LOGOFF         Log the user off. Applications will be told to quit so you may be prompted to save files.

EWX_POWEROFF     Force the system to shutdown and power off. The system must support power off.

EWX_REBOOT         Shut down the system and reboot the computer.

EWX_SHUTDOWN     Shut down the system but don't reboot.

For all of these flags except EWX_LOGOFF, your process must have the "Shut Down The System" privilege.

You can also force the system to do its business without saving unsaved buffers by logically OR'ing the EWX_FORCE flag with any of the others. So to force the user to log off without concern for unsaved files, you'd use

Win32::AdminMisc::ExitWindows(EWX_LOGOFF | EWX_FORCE)

Of course, if you do this to the wrong person at the wrong time, you might find yourself in the unemployment line.

Maintaining scheduled jobs. In the beginning of this article, I wrote about scheduling jobs on an NT machine. The scheduler service is an incredibly powerful tool; on my LAN I use it religiously. Every night each workstation runs a batch file to perform routine maintenance, install software, update templates, update our SQL-based machine database (containing information about each computer, alerting us when a workstation is running low on disk space), synchronize the computer's time and date and a slew of other things. I can also add a job to a particular machine if I want that workstation to do something special like reboot or log off the user at a particular time. Very powerful.

In addition to adding scheduled jobs to a workstation (locally or remotely), you can query a machine for the list of scheduled jobs. Here's how to find out how many jobs have been scheduled:

$Num = Win32::AdminMisc::ScheduleList("//computer")

Here's how to get both the number of jobs and a list of their names:

$Num = Win32::AdminMisc::ScheduleList("//computer", \%List) 

The %List hash will contain subhashes, one per job, with information about each. Keep in mind that if there are many jobs %List can become quite large. You can request information about a single job with

Win32::AdminMisc::ScheduleGet("//computer", $JobNumber, \%Info)

This populates the %Info hash with information about the job numbered $JobNumber. The function returns a 1 if successful and a 0 if not. The information stored in the hashes for both ScheduleList() and ScheduleGet() is the same as for the ScheduleAdd() function, demonstrated in the first program in this article.

You can also delete scheduled jobs, either one at a time or within a range. To delete a specific job:

Win32::AdminMisc::ScheduleDel("//computer", $JobNumber) 

This deletes job number $JobNumber on //computer. To delete all jobs within a range:


This removes all jobs from $JobNumber and $MaxJobNumber, inclusive. You may have noticed that I use //computer as a computer name even though it's not a "proper" Microsoft computer name; these functions recognize both \\computer and //computer as valid names - I get awfully tired of escaping my backslashes, as in the Microsoft-proper "\\\\computer".

Get the Time Of Day. What if you need to discover the TOD on a remote machine? This is more helpful than you might think - if you want a remote machine to run a job five minutes from now, it helps to know what the remote machine thinks "now" is.

$Time = Win32::AdminMisc::GetTOD($Machine); 
print "The time on $Machine is ", localtime($Time), ".\n";

If you don't specify a $Machine then the local machine's time is returned.

All things considered. The Win32::NetAdmin and Win32::AdminMisc modules let you automate many of the tasks that every NT administrator needs to perform. They convert tedium to leisure; use them well.

_ _END_ _

When Dave Roth isn't collapsing wave functions he preaches Perl to his clients. He can be reached at