Пример #1
0
        /// <summary>
        /// Send mail and kicks off the send in a new task
        /// </summary>
        /// <param name="foundUser">Found user</param>
        /// <param name="reader">Reader</param>
        /// <param name="writer">Writer</param>
        /// <param name="line">Line</param>
        /// <param name="endPoint">End point</param>
        /// <param name="prepMessage">Allow changing mime message right before it is sent</param>
        /// <returns>Task</returns>
        private async Task SendMail(MailDemonUser foundUser, Stream reader, StreamWriter writer, string line, IPEndPoint endPoint, Action <MimeMessage> prepMessage)
        {
            MailFromResult result = await ParseMailFrom(foundUser, reader, writer, line, endPoint);

            SendMail(writer, result, endPoint, true, prepMessage).GetAwaiter();
            await writer.WriteLineAsync($"250 2.1.0 OK");

            await writer.FlushAsync();
        }
Пример #2
0
        private async Task <MailDemonUser> AuthenticateLogin(Stream reader, StreamWriter writer, string line)
        {
            MailDemonUser foundUser = null;
            await writer.WriteLineAsync("334 VXNlcm5hbWU6"); // user

            await writer.FlushAsync();

            string userName = await ReadLineAsync(reader) ?? string.Empty;

            await writer.WriteLineAsync("334 UGFzc3dvcmQ6"); // pwd

            await writer.FlushAsync();

            string password = await ReadLineAsync(reader) ?? string.Empty;

            userName = Encoding.UTF8.GetString(Convert.FromBase64String(userName)).Trim();
            password = Encoding.UTF8.GetString(Convert.FromBase64String(password));
            string sentAuth = userName + ":" + password;

            foreach (MailDemonUser user in users)
            {
                if (user.Authenticate(userName, password))
                {
                    foundUser = user;
                    break;
                }
            }
            if (foundUser != null)
            {
                MailDemonLog.Info("User {0} authenticated", foundUser.UserName);
                await writer.WriteLineAsync($"235 2.7.0 Accepted");

                await writer.FlushAsync();

                return(foundUser);
            }

            // fail
            MailDemonLog.Warn("Authentication failed: {0}", sentAuth);
            await writer.WriteLineAsync($"535 authentication failed");

            await writer.FlushAsync();

            return(new MailDemonUser(userName, userName, password, userName, null, false, false));
        }
Пример #3
0
        private async Task <bool> ReceiveMail(Stream reader, StreamWriter writer, string line, IPEndPoint endPoint)
        {
            IPHostEntry entry = await Dns.GetHostEntryAsync(endPoint.Address);

            MailFromResult result = await ParseMailFrom(null, reader, writer, line, endPoint);

            if (result is null)
            {
                return(false);
            }
            try
            {
                string      subject;
                MimeMessage msg;
                using (Stream stream = File.OpenRead(result.BackingFile))
                {
                    msg = await MimeMessage.LoadAsync(stream, true, cancelToken);

                    subject = msg.Subject;
                }
                subject = (subject ?? string.Empty).Trim();
                if (subject.Equals("unsubscribe", StringComparison.OrdinalIgnoreCase))
                {
                    UnsubscribeHandler?.Invoke(result.From.Address, subject, msg.HtmlBody);
                    return(true);
                }

                // mail demon doesn't have an inbox yet, only forwarding, so see if any of the to addresses can be forwarded
                foreach (var kv in result.ToAddresses)
                {
                    foreach (MailboxAddress address in kv.Value)
                    {
                        MailDemonUser user = users.FirstOrDefault(u => u.MailAddress.Address.Equals(address.Address, StringComparison.OrdinalIgnoreCase));

                        // if no user or the forward address points to a user, fail
                        if (user == null || users.FirstOrDefault(u => u.MailAddress.Address.Equals(user.ForwardAddress.Address, StringComparison.Ordinal)) != null)
                        {
                            await writer.WriteLineAsync($"500 invalid command - user not found");

                            await writer.FlushAsync();
                        }

                        // setup forward headers
                        MailboxAddress forwardToAddress = (user.ForwardAddress ?? globalForwardAddress);
                        if (forwardToAddress == null)
                        {
                            await writer.WriteLineAsync($"500 invalid command - user not found 2");

                            await writer.FlushAsync();
                        }
                        else
                        {
                            string forwardDomain = forwardToAddress.Address.Substring(forwardToAddress.Address.IndexOf('@') + 1);

                            // create new object to forward on
                            MailFromResult newResult = new MailFromResult
                            {
                                BackingFile = result.BackingFile,
                                From        = user.MailAddress,
                                ToAddresses = new Dictionary <string, IEnumerable <MailboxAddress> > {
                                    { forwardDomain, new List <MailboxAddress> {
                                          forwardToAddress
                                      } }
                                }
                            };

                            // forward the message on and clear the forward headers
                            MailDemonLog.Info("Forwarding message, from: {0}, to: {1}, forward: {2}", result.From, address, forwardToAddress);
                            result.BackingFile = null; // we took ownership of the file

                            // send in background
                            SendMail(newResult, true, prepMsg =>
                            {
                                prepMsg.Subject = $"FW from {result.From}: {prepMsg.Subject}";
                                prepMsg.Cc.Clear();
                                prepMsg.Bcc.Clear();
                            }, false).ConfigureAwait(false).GetAwaiter();
                            return(true); // only forward to the first valid address
                        }
                    }
                }
            }
            finally
            {
                result.Dispose();
            }
            return(true);
        }
Пример #4
0
        public MailDemonService(string[] args, IConfiguration configuration)
        {
            IConfigurationSection rootSection = configuration.GetSection("mailDemon");

            Domain = (rootSection["domain"] ?? Domain);
            ip     = (string.IsNullOrWhiteSpace(rootSection["ip"]) ? IPAddress.Any : IPAddress.Parse(rootSection["ip"]));
            port   = rootSection.GetValue("port", port);
            maxFailuresPerIPAddress = rootSection.GetValue("maxFailuresPerIPAddress", maxFailuresPerIPAddress);
            maxConnectionCount      = rootSection.GetValue("maxConnectionCount", maxConnectionCount);
            maxMessageSize          = rootSection.GetValue("maxMessageSize", maxMessageSize);
            globalForwardAddress    = rootSection.GetValue("globalForwardAddress", globalForwardAddress);
            greeting = (rootSection["greeting"] ?? greeting).Replace("\r", string.Empty).Replace("\n", string.Empty);
            if (TimeSpan.TryParse(rootSection["failureLockoutTimespan"], out TimeSpan _failureLockoutTimespan))
            {
                failureLockoutTimespan = _failureLockoutTimespan;
            }
            failureLockoutTimespan = _failureLockoutTimespan;
            IConfigurationSection userSection = rootSection.GetSection("users");

            foreach (var child in userSection.GetChildren())
            {
                MailDemonUser user = new MailDemonUser(child["name"], child["displayName"], child["password"], child["address"], child["forwardAddress"], true);
                users.Add(user);
                MailDemonLog.Debug("Loaded user {0}", user);
            }
            requireEhloIpHostMatch = rootSection.GetValue <bool>("requireEhloIpHostMatch", requireEhloIpHostMatch);
            requireSpfMatch        = rootSection.GetValue <bool>("requireSpfMatch", requireSpfMatch);
            string dkimFile     = rootSection.GetValue <string>("dkimPemFile", null);
            string dkimSelector = rootSection.GetValue <string>("dkimSelector", null);

            if (File.Exists(dkimFile) && !string.IsNullOrWhiteSpace(dkimSelector))
            {
                try
                {
                    using (StringReader stringReader = new StringReader(File.ReadAllText(dkimFile)))
                    {
                        PemReader pemReader = new PemReader(stringReader);
                        object    pemObject = pemReader.ReadObject();
                        AsymmetricKeyParameter privateKey = ((AsymmetricCipherKeyPair)pemObject).Private;
                        dkimSigner = new DkimSigner(privateKey, Domain, dkimSelector);
                        MailDemonLog.Info("Loaded dkim file at {0}", dkimFile);
                    }
                }
                catch (Exception ex)
                {
                    MailDemonLog.Error(ex);
                }
            }
            sslCertificateFile           = rootSection["sslCertificateFile"];
            sslCertificatePrivateKeyFile = rootSection["sslCertificatePrivateKeyFile"];
            if (!string.IsNullOrWhiteSpace(sslCertificateFile))
            {
                sslCertificatePassword = (rootSection["sslCertificatePassword"] ?? string.Empty).ToSecureString();
            }
            TestSslCertificate();
            IConfigurationSection ignoreRegexSection = rootSection.GetSection("ignoreCertificateErrorsRegex");

            if (ignoreRegexSection != null)
            {
                foreach (var child in ignoreRegexSection.GetChildren())
                {
                    Regex re = new Regex(child["regex"].ToString(), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Singleline);
                    foreach (var domain in child.GetSection("domains").GetChildren())
                    {
                        ignoreCertificateErrorsRegex[domain.Value] = re;
                    }
                }
            }
        }
Пример #5
0
        private async Task <MailFromResult> ParseMailFrom(MailDemonUser fromUser, Stream reader, StreamWriter writer, string line, IPEndPoint endPoint)
        {
            string fromAddress = line.Substring(11);
            int    pos         = fromAddress.IndexOf('>');

            if (pos >= 0)
            {
                fromAddress = fromAddress.Substring(0, pos);
            }

            // if this is an anonymous user, ensure spf is a match
            if (fromUser == null || !fromUser.Authenticated)
            {
                // validate spf
                await ValidateSPF(writer, endPoint, fromAddress, fromAddress.Substring(fromAddress.IndexOf('@') + 1));
            }

            bool binaryMime = (line.Contains("BODY=BINARYMIME", StringComparison.OrdinalIgnoreCase));

            if (!fromAddress.TryParseEmailAddress(out _))
            {
                await writer.WriteLineAsync($"500 invalid command - bad from address format");

                await writer.FlushAsync();

                throw new ArgumentException("Invalid format for from address: " + fromAddress);
            }

            if (fromUser != null && !fromUser.MailAddress.Address.Equals(fromAddress))
            {
                await writer.WriteLineAsync($"500 invalid command - bad from address");

                await writer.FlushAsync();

                throw new InvalidOperationException($"Invalid from address - bad from address '{fromAddress}'");
            }

            // denote success for sender and binarymime
            string binaryMimeOk = (binaryMime ? " and BINARYMIME" : string.Empty);
            string fromUserName = (fromUser == null ? fromAddress : fromUser.UserName);
            await writer.WriteLineAsync($"250 2.1.0 sender {fromUserName}{binaryMimeOk} OK");

            // read to addresses
            line = await ReadLineAsync(reader);

            if (line.Equals("QUIT", StringComparison.OrdinalIgnoreCase))
            {
                return(null);
            }

            Dictionary <string, IEnumerable <MailboxAddress> > toAddressesByDomain = new Dictionary <string, IEnumerable <MailboxAddress> >(StringComparer.OrdinalIgnoreCase);

            while (line.StartsWith("RCPT TO:<", StringComparison.OrdinalIgnoreCase))
            {
                string toAddress = line.Substring(9).Trim('>');
                if (!toAddress.TryParseEmailAddress(out MailboxAddress toAddressMail))
                {
                    await writer.WriteLineAsync($"500 invalid command - bad to address format for address '{toAddress}'");

                    await writer.FlushAsync();

                    throw new ArgumentException($"Invalid to address '{toAddress}'");
                }

                // if no authenticated user, the to address must match an existing user address
                else if (fromUser == null && users.FirstOrDefault(u => u.MailAddress.Address.Equals(toAddress, StringComparison.OrdinalIgnoreCase)) == null)
                {
                    await writer.WriteLineAsync($"500 invalid command - bad to address '{toAddress}'");

                    await writer.FlushAsync();

                    throw new InvalidOperationException($"Invalid to address '{toAddress}'");
                }
                // else user is authenticated, can send email to anyone

                // group addresses by domain
                pos = toAddress.LastIndexOf('@');
                if (pos > 0)
                {
                    string addressDomain = toAddress.Substring(++pos);
                    if (!toAddressesByDomain.TryGetValue(addressDomain, out IEnumerable <MailboxAddress> addressList))
                    {
                        toAddressesByDomain[addressDomain] = addressList = new List <MailboxAddress>();
                    }
                    (addressList as List <MailboxAddress>).Add(toAddressMail);
                }

                // denote success for recipient
                await writer.WriteLineAsync($"250 2.1.0 recipient {toAddress} OK");

                line = await ReadLineAsync(reader);
            }

            // if no to addresses, fail
            if (toAddressesByDomain.Count == 0)
            {
                await writer.WriteLineAsync($"500 invalid command - no to address");

                await writer.FlushAsync();

                throw new InvalidOperationException("Invalid message: " + line);
            }

            if (line.StartsWith("DATA", StringComparison.OrdinalIgnoreCase))
            {
                if (binaryMime)
                {
                    await writer.WriteLineAsync("503 5.5.1 Bad sequence of commands, BODY=BINARYMIME requires BDAT, not DATA");

                    await writer.FlushAsync();

                    throw new InvalidOperationException("Invalid message: " + line);
                }
                await writer.WriteLineAsync($"354");

                string tempFile   = Path.GetTempFileName();
                int    totalCount = 0;
                try
                {
                    using (Stream tempFileWriter = File.OpenWrite(tempFile))
                    {
                        int b;
                        int state = 0;
                        while (state != 5 && (b = reader.ReadByte()) >= 0)
                        {
                            if (b == (byte)'.')
                            {
                                if (state == 2)
                                {
                                    // \r\n.
                                    state = 3;
                                }
                                else
                                {
                                    // reset
                                    state = 0;
                                }
                            }
                            else if (b == (byte)'\r')
                            {
                                if (state == 3)
                                {
                                    // \r\n.\r
                                    state = 4;
                                }
                                else
                                {
                                    // \r
                                    state = 1;
                                }
                            }
                            else if (b == (byte)'\n')
                            {
                                if (state == 1)
                                {
                                    // \r\n
                                    state = 2;
                                }
                                else if (state == 4)
                                {
                                    // \r\n.\r\n
                                    state = 5;
                                }
                                else
                                {
                                    // reset
                                    state = 0;
                                }
                            }
                            else
                            {
                                // reset
                                state = 0;
                            }
                            totalCount++;
                            if (totalCount > maxMessageSize)
                            {
                                await writer.WriteLineAsync("552 message too large");

                                throw new InvalidOperationException("Invalid message: " + line);
                            }
                            tempFileWriter.WriteByte((byte)b);
                        }
                    }

                    // strip off the \r\n.\r\n, that is part of the protocol
                    using (FileStream tempFileStream = File.Open(tempFile, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
                    {
                        if (tempFileStream.Length >= 5)
                        {
                            tempFileStream.SetLength(tempFileStream.Length - 5);
                        }
                    }
                    await writer.WriteLineAsync($"250 2.5.0 OK");

                    return(new MailFromResult
                    {
                        BackingFile = tempFile,
                        From = (fromUser == null ? new MailboxAddress(fromAddress) : fromUser.MailAddress),
                        ToAddresses = toAddressesByDomain
                    });
                }
                catch
                {
                    File.Delete(tempFile);
                    throw;
                }
            }
            else if (line.StartsWith("BDAT", StringComparison.OrdinalIgnoreCase))
            {
                // https://tools.ietf.org/html/rfc1830
                string tempFile   = Path.GetTempFileName();
                bool   last       = false;
                int    totalBytes = 0;

                try
                {
                    // send bdat to temp file to avoid memory issues
                    using (Stream stream = File.OpenWrite(tempFile))
                    {
                        do
                        {
                            int space  = line.IndexOf(' ');
                            int space2 = line.IndexOf(' ', space + 1);
                            if (space2 < 0)
                            {
                                space2 = line.Length;
                            }
                            if (space < 0 || !int.TryParse(line.AsSpan(space, space2 - space),
                                                           NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite, CultureInfo.InvariantCulture, out int size))
                            {
                                await writer.WriteLineAsync($"500 invalid command");

                                throw new InvalidOperationException("Invalid message: " + line);
                            }
                            last        = line.Contains("LAST", StringComparison.OrdinalIgnoreCase);
                            totalBytes += size;
                            if (totalBytes > maxMessageSize)
                            {
                                await writer.WriteLineAsync("552 message too large");

                                throw new InvalidOperationException("Invalid message: " + line);
                            }
                            await ReadWriteAsync(reader, stream, size);

                            if (last)
                            {
                                await writer.WriteLineAsync($"250 2.5.0 total {totalBytes} bytes received message OK");
                            }
                            else
                            {
                                await writer.WriteLineAsync($"250 2.0.0 {size} bytes received OK");
                            }
                        }while (!last && !cancelToken.IsCancellationRequested && (line = await ReadLineAsync(reader)) != null);
                    }
                    Stream fileStream = null;
                    try
                    {
                        fileStream = File.OpenRead(tempFile);
                        return(new MailFromResult
                        {
                            BackingFile = tempFile,
                            From = (fromUser == null ? new MailboxAddress(fromAddress) : fromUser.MailAddress),
                            ToAddresses = toAddressesByDomain
                        });
                    }
                    catch
                    {
                        fileStream?.Dispose();
                        throw;
                    }
                }
                catch
                {
                    File.Delete(tempFile);
                    throw;
                }
            }
            else
            {
                await writer.WriteLineAsync($"500 invalid command");

                await writer.FlushAsync();

                throw new InvalidOperationException("Invalid line in mail from: " + line);
            }
        }
Пример #6
0
        private async Task HandleClientConnectionAsync(TcpClient tcpClient)
        {
            if (tcpClient is null || tcpClient.Client is null || !tcpClient.Client.Connected)
            {
                return;
            }

            DateTime         start             = DateTime.UtcNow;
            string           ipAddress         = (tcpClient.Client.RemoteEndPoint as IPEndPoint).Address.ToString();
            MailDemonUser    authenticatedUser = null;
            NetworkStream    clientStream      = null;
            X509Certificate2 sslCert           = null;
            SslStream        sslStream         = null;
            bool             helo = false;

            try
            {
                tcpClient.ReceiveTimeout = tcpClient.SendTimeout = streamTimeoutMilliseconds;

                MailDemonLog.Info("Connection from {0}", ipAddress);

                // immediately drop if client is blocked
                if (CheckBlocked(ipAddress))
                {
                    MailDemonLog.Warn("Blocked {0}", ipAddress);
                    return;
                }

                clientStream = tcpClient.GetStream();

                // create comm streams
                clientStream.ReadTimeout = clientStream.WriteTimeout = streamTimeoutMilliseconds;
                Stream       reader = clientStream;
                StreamWriter writer = new StreamWriter(clientStream, MailDemonExtensionMethods.Utf8EncodingNoByteMarker)
                {
                    AutoFlush = true, NewLine = "\r\n"
                };

                async Task StartSSL()
                {
                    sslCert = await CertificateCache.Instance.LoadSslCertificateAsync(sslCertificateFile, sslCertificatePrivateKeyFile, sslCertificatePassword);

                    Tuple <SslStream, Stream, StreamWriter> tls = await StartTls(tcpClient, ipAddress, reader, writer, true, sslCert);

                    if (tls == null)
                    {
                        await writer.WriteLineAsync("503 Failed to start TLS");

                        await writer.FlushAsync();

                        throw new IOException("Failed to start TLS, ssl certificate failed to load");
                    }
                    else
                    {
                        sslStream = tls.Item1;
                        reader    = tls.Item2;
                        writer    = tls.Item3;
                    }
                }

                if (port == 465 || port == 587)
                {
                    await StartSSL();
                }

                MailDemonLog.Info("Connection accepted from {0}", ipAddress);

                // send greeting
                await writer.WriteLineAsync($"220 {Domain} {greeting}");

                await writer.FlushAsync();

                IPEndPoint endPoint = tcpClient.Client.RemoteEndPoint as IPEndPoint;

                while (true)
                {
                    if ((DateTime.UtcNow - start) > sessionTimeout)
                    {
                        throw new TimeoutException($"Session expired after {sessionTimeout.TotalMinutes:0.00} minutes");
                    }

                    string line = await ReadLineAsync(reader);

                    // these commands are allowed before HELO
                    if (string.IsNullOrWhiteSpace(line) || line.StartsWith("QUIT", StringComparison.OrdinalIgnoreCase))
                    {
                        await writer.WriteLineAsync("221 session terminated");

                        await writer.FlushAsync();

                        break;
                    }
                    else if (line.StartsWith("EHLO", StringComparison.OrdinalIgnoreCase))
                    {
                        await HandleEhlo(writer, line, sslStream, sslCert, endPoint);

                        helo = true;
                    }
                    else if (line.StartsWith("STARTTLS", StringComparison.OrdinalIgnoreCase))
                    {
                        if (sslStream != null)
                        {
                            await writer.WriteLineAsync("503 TLS already initiated");

                            await writer.FlushAsync();
                        }
                        else
                        {
                            await StartSSL();
                        }
                    }
                    else if (line.StartsWith("HELO", StringComparison.OrdinalIgnoreCase))
                    {
                        await HandleHelo(writer, line, endPoint);

                        helo = true;
                    }
                    else if (line.StartsWith("NOOP", StringComparison.OrdinalIgnoreCase))
                    {
                        await writer.WriteLineAsync("220 OK");

                        await writer.FlushAsync();
                    }
                    else if (line.StartsWith("HELP", StringComparison.OrdinalIgnoreCase))
                    {
                        await writer.WriteLineAsync("220 OK Please use EHLO command");

                        await writer.FlushAsync();
                    }
                    else if (!helo)
                    {
                        throw new InvalidOperationException("Client did not send greeting before line " + line);
                    }

                    // these commands may only appear after HELO/EHLO
                    else if (line.StartsWith("RSET", StringComparison.OrdinalIgnoreCase))
                    {
                        await writer.WriteLineAsync($"250 2.0.0 Resetting");

                        await writer.FlushAsync();

                        authenticatedUser = null;
                    }
                    else if (line.StartsWith("AUTH PLAIN", StringComparison.OrdinalIgnoreCase))
                    {
                        authenticatedUser = await AuthenticatePlain(reader, writer, line);

                        if (authenticatedUser.Authenticated && tcpClient.Client.RemoteEndPoint is IPEndPoint remoteEndPoint)
                        {
                            IPBan.IPBanPlugin.IPBanLoginSucceeded("SMTP", authenticatedUser.UserName, remoteEndPoint.Address.ToString());
                        }
                        else
                        {
                            throw new InvalidOperationException("Authentication failed");
                        }
                    }
                    else if (line.StartsWith("AUTH LOGIN", StringComparison.OrdinalIgnoreCase))
                    {
                        authenticatedUser = await AuthenticateLogin(reader, writer, line);

                        if (authenticatedUser.Authenticated && tcpClient.Client.RemoteEndPoint is IPEndPoint remoteEndPoint)
                        {
                            IPBan.IPBanPlugin.IPBanLoginSucceeded("SMTP", authenticatedUser.UserName, remoteEndPoint.Address.ToString());
                        }
                        else
                        {
                            throw new InvalidOperationException("Authentication failed");
                        }
                    }

                    // if authenticated, only valid line is MAIL FROM
                    // TODO: consider changing this
                    else if (authenticatedUser != null)
                    {
                        if (line.StartsWith("MAIL FROM:<", StringComparison.OrdinalIgnoreCase))
                        {
                            try
                            {
                                await SendMail(authenticatedUser, reader, writer, line, endPoint, null);
                            }
                            catch (Exception ex)
                            {
                                throw new ApplicationException("Error sending mail from " + endPoint, ex);
                            }
                        }
                        else
                        {
                            MailDemonLog.Warn("Ignoring client command: {0}", line);
                        }
                    }
                    else
                    {
                        if (line.StartsWith("MAIL FROM:<", StringComparison.OrdinalIgnoreCase))
                        {
                            // non-authenticated user, forward message on if possible, check settings
                            try
                            {
                                bool result = await ReceiveMail(reader, writer, line, endPoint);

                                if (!result)
                                {
                                    await writer.WriteLineAsync("221 session terminated");

                                    await writer.FlushAsync();

                                    break;
                                }
                            }
                            catch (Exception ex)
                            {
                                throw new ApplicationException("Error receiving mail from " + endPoint, ex);
                            }
                        }
                        else
                        {
                            throw new InvalidOperationException("Invalid message: " + line + ", not authenticated");
                        }
                    }
                }
            }
            catch (Exception ex)
            {
                if (!(ex is SocketException))
                {
                    IncrementFailure(ipAddress, authenticatedUser?.UserName);
                    MailDemonLog.Error(ex, "{0} error", ipAddress);
                }
            }
            finally
            {
                sslStream?.Dispose();
                clientStream?.Dispose();
                MailDemonLog.Info("{0} disconnected", ipAddress);
            }
        }