diff --git a/services.conf b/services.conf new file mode 100644 index 0000000..66d7c5f --- /dev/null +++ b/services.conf @@ -0,0 +1,14 @@ +# Service Monitor - Configuración +# ================================ + +# Servicios HTTP +http,Forgejo (HTTP),https://git.reugenio.com +http,Simifactu API,https://simifactu.com +http,Mundisofa,https://mundisofa.com +http,Menzuri,https://menzuri.com + +# Servicios TCP +tcp,Forgejo (SSH),git.reugenio.com,2222 + +# Telegram +telegram,8158165444:AAFxUjLChsuusgFD5B1gt2svt8NflvAm1M8,1481345275 diff --git a/src/main.zig b/src/main.zig index 74a5dd0..187fee5 100644 --- a/src/main.zig +++ b/src/main.zig @@ -16,6 +16,8 @@ const tcp = @import("tcp.zig"); const config = @import("config.zig"); const notify = @import("notify.zig"); const daemon = @import("daemon.zig"); +const smtp = @import("smtp.zig"); +const telegram = @import("telegram.zig"); /// Archivo de log por defecto. const DEFAULT_LOG_FILE = "service-monitor.log"; @@ -128,12 +130,12 @@ pub fn main() !void { } while (true) { - _ = try runChecks(allocator, output_writer, log_file, options.notify, cfg.services); + _ = try runChecks(allocator, output_writer, log_file, options.notify, &cfg); std.time.sleep(@as(u64, options.interval_seconds) * std.time.ns_per_s); } } else { try stdout.print("\n=== Service Monitor ===\n\n", .{}); - const had_errors = try runChecks(allocator, stdout, log_file, options.notify, cfg.services); + const had_errors = try runChecks(allocator, stdout, log_file, options.notify, &cfg); if (had_errors) { std.process.exit(1); @@ -147,11 +149,12 @@ fn runChecks( stdout: ?std.fs.File.Writer, log_file: ?std.fs.File, notify_enabled: bool, - services: []const config.Service, + cfg: *const config.Config, ) !bool { var had_errors = false; var error_count: u32 = 0; var error_services: [16][]const u8 = undefined; + const services = cfg.services; // Timestamp const timestamp = std.time.timestamp(); @@ -237,6 +240,54 @@ fn runChecks( notify.send(allocator, "⚠️ Servicios caídos", body_buf[0..body_len], "critical") catch {}; } + // Enviar email si hay configuración SMTP y destinatarios + if (had_errors and cfg.smtp.host.len > 0 and cfg.email_recipients.len > 0) { + var body_buf: [1024]u8 = undefined; + var body_len: usize = 0; + + // Construir cuerpo del email + const header = "Los siguientes servicios no responden:\n\n"; + @memcpy(body_buf[0..header.len], header); + body_len = header.len; + + for (error_services[0..error_count]) |svc_name| { + if (body_len + svc_name.len + 3 < body_buf.len) { + body_buf[body_len] = '-'; + body_buf[body_len + 1] = ' '; + body_len += 2; + @memcpy(body_buf[body_len..][0..svc_name.len], svc_name); + body_len += svc_name.len; + body_buf[body_len] = '\n'; + body_len += 1; + } + } + + smtp.sendEmail(allocator, .{ + .host = cfg.smtp.host, + .port = cfg.smtp.port, + .username = cfg.smtp.username, + .password = cfg.smtp.password, + .from = if (cfg.smtp.from.len > 0) cfg.smtp.from else cfg.smtp.username, + .to = cfg.email_recipients, + .subject = "[Service Monitor] Alerta: Servicios caídos", + .body = body_buf[0..body_len], + }) catch { + // Ignorar errores de email, no son críticos + }; + } + + // Enviar Telegram si hay configuración + if (had_errors and cfg.telegram.bot_token.len > 0 and cfg.telegram.chat_id.len > 0) { + telegram.sendAlert( + allocator, + cfg.telegram.bot_token, + cfg.telegram.chat_id, + error_services[0..error_count], + ) catch { + // Ignorar errores de Telegram, no son críticos + }; + } + return had_errors; } diff --git a/src/smtp.zig b/src/smtp.zig new file mode 100644 index 0000000..bca4ba7 --- /dev/null +++ b/src/smtp.zig @@ -0,0 +1,261 @@ +//! Cliente SMTP para envío de emails +//! +//! Implementación del protocolo SMTP (RFC 5321) con soporte para +//! autenticación AUTH LOGIN y STARTTLS. +//! +//! Ejemplo de uso: +//! ```zig +//! try smtp.sendEmail(allocator, .{ +//! .host = "smtp.gmail.com", +//! .port = 587, +//! .username = "usuario@gmail.com", +//! .password = "app_password", +//! .from = "usuario@gmail.com", +//! .to = &[_][]const u8{"destino@ejemplo.com"}, +//! .subject = "Alerta", +//! .body = "Servicio caído", +//! }); +//! ``` + +const std = @import("std"); + +/// Errores posibles durante el envío SMTP. +pub const SmtpError = error{ + /// No se pudo conectar al servidor SMTP. + ConnectionFailed, + /// El servidor cerró la conexión inesperadamente. + ConnectionClosed, + /// Respuesta inesperada del servidor. + UnexpectedResponse, + /// Fallo en autenticación. + AuthenticationFailed, + /// El servidor rechazó el remitente. + SenderRejected, + /// El servidor rechazó el destinatario. + RecipientRejected, + /// Error enviando el mensaje. + MessageRejected, + /// Timeout esperando respuesta. + Timeout, + /// Error de red. + NetworkError, + /// Buffer demasiado pequeño. + BufferTooSmall, +}; + +/// Opciones para enviar un email. +pub const EmailOptions = struct { + /// Servidor SMTP. + host: []const u8, + /// Puerto (25, 465, 587). + port: u16 = 587, + /// Usuario para autenticación. + username: []const u8 = "", + /// Contraseña para autenticación. + password: []const u8 = "", + /// Dirección del remitente. + from: []const u8, + /// Direcciones de destinatarios. + to: []const []const u8, + /// Asunto del email. + subject: []const u8, + /// Cuerpo del email (texto plano). + body: []const u8, +}; + +/// Envía un email usando SMTP. +/// +/// Soporta autenticación AUTH LOGIN. Para servidores que requieren +/// TLS (como Gmail en puerto 587), se necesita STARTTLS que aún +/// no está implementado - usar puerto 465 (SMTPS) como alternativa. +pub fn sendEmail(allocator: std.mem.Allocator, options: EmailOptions) SmtpError!void { + // Conectar al servidor + const stream = std.net.tcpConnectToHost(allocator, options.host, options.port) catch { + return SmtpError.ConnectionFailed; + }; + defer stream.close(); + + var read_buffer: [1024]u8 = undefined; + + // Recibir saludo (220) + try expectResponse(stream, &read_buffer, "220"); + + // EHLO + try sendCommand(stream, "EHLO localhost\r\n"); + try expectResponse(stream, &read_buffer, "250"); + + // Autenticación si hay credenciales + if (options.username.len > 0 and options.password.len > 0) { + try sendCommand(stream, "AUTH LOGIN\r\n"); + try expectResponse(stream, &read_buffer, "334"); + + // Usuario en Base64 + var user_b64: [256]u8 = undefined; + const user_encoded = base64Encode(options.username, &user_b64) catch { + return SmtpError.BufferTooSmall; + }; + try sendLine(stream, user_encoded); + try expectResponse(stream, &read_buffer, "334"); + + // Password en Base64 + var pass_b64: [256]u8 = undefined; + const pass_encoded = base64Encode(options.password, &pass_b64) catch { + return SmtpError.BufferTooSmall; + }; + try sendLine(stream, pass_encoded); + + const auth_response = try readResponse(stream, &read_buffer); + if (!std.mem.startsWith(u8, auth_response, "235")) { + return SmtpError.AuthenticationFailed; + } + } + + // MAIL FROM + var from_cmd: [512]u8 = undefined; + const from_len = std.fmt.bufPrint(&from_cmd, "MAIL FROM:<{s}>\r\n", .{options.from}) catch { + return SmtpError.BufferTooSmall; + }; + try sendCommand(stream, from_len); + + const from_response = try readResponse(stream, &read_buffer); + if (!std.mem.startsWith(u8, from_response, "250")) { + return SmtpError.SenderRejected; + } + + // RCPT TO (para cada destinatario) + for (options.to) |recipient| { + var rcpt_cmd: [512]u8 = undefined; + const rcpt_len = std.fmt.bufPrint(&rcpt_cmd, "RCPT TO:<{s}>\r\n", .{recipient}) catch { + return SmtpError.BufferTooSmall; + }; + try sendCommand(stream, rcpt_len); + + const rcpt_response = try readResponse(stream, &read_buffer); + if (!std.mem.startsWith(u8, rcpt_response, "250")) { + return SmtpError.RecipientRejected; + } + } + + // DATA + try sendCommand(stream, "DATA\r\n"); + try expectResponse(stream, &read_buffer, "354"); + + // Headers + Body + var msg_buffer: [4096]u8 = undefined; + const msg = std.fmt.bufPrint(&msg_buffer, + \\From: {s} + \\To: {s} + \\Subject: {s} + \\Content-Type: text/plain; charset=UTF-8 + \\ + \\{s} + \\. + \\ + , .{ + options.from, + options.to[0], // Primer destinatario en header + options.subject, + options.body, + }) catch { + return SmtpError.BufferTooSmall; + }; + + // Convertir \n a \r\n para SMTP + try sendSmtpData(stream, msg); + + const data_response = try readResponse(stream, &read_buffer); + if (!std.mem.startsWith(u8, data_response, "250")) { + return SmtpError.MessageRejected; + } + + // QUIT + try sendCommand(stream, "QUIT\r\n"); + // No esperamos respuesta de QUIT, solo cerramos +} + +/// Lee una respuesta del servidor SMTP. +fn readResponse(stream: std.net.Stream, buffer: []u8) SmtpError![]const u8 { + const bytes_read = stream.read(buffer) catch { + return SmtpError.NetworkError; + }; + + if (bytes_read == 0) { + return SmtpError.ConnectionClosed; + } + + return buffer[0..bytes_read]; +} + +/// Espera una respuesta que empiece con el código dado. +fn expectResponse(stream: std.net.Stream, buffer: []u8, expected_code: []const u8) SmtpError!void { + const response = try readResponse(stream, buffer); + + if (!std.mem.startsWith(u8, response, expected_code)) { + return SmtpError.UnexpectedResponse; + } +} + +/// Envía un comando al servidor. +fn sendCommand(stream: std.net.Stream, command: []const u8) SmtpError!void { + _ = stream.write(command) catch { + return SmtpError.NetworkError; + }; +} + +/// Envía una línea con CRLF al final. +fn sendLine(stream: std.net.Stream, line: []const u8) SmtpError!void { + _ = stream.write(line) catch { + return SmtpError.NetworkError; + }; + _ = stream.write("\r\n") catch { + return SmtpError.NetworkError; + }; +} + +/// Envía datos SMTP convirtiendo \n a \r\n. +fn sendSmtpData(stream: std.net.Stream, data: []const u8) SmtpError!void { + var i: usize = 0; + while (i < data.len) { + const start = i; + // Buscar siguiente \n + while (i < data.len and data[i] != '\n') : (i += 1) {} + + // Enviar hasta aquí + if (i > start) { + _ = stream.write(data[start..i]) catch { + return SmtpError.NetworkError; + }; + } + + // Si encontramos \n, enviar \r\n + if (i < data.len and data[i] == '\n') { + _ = stream.write("\r\n") catch { + return SmtpError.NetworkError; + }; + i += 1; + } + } +} + +/// Codifica datos en Base64. +fn base64Encode(input: []const u8, output: []u8) ![]const u8 { + const encoder = std.base64.standard.Encoder; + const encoded_len = encoder.calcSize(input.len); + + if (encoded_len > output.len) { + return error.BufferTooSmall; + } + + const result = encoder.encode(output, input); + return result; +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "base64 encode" { + var buffer: [64]u8 = undefined; + const result = try base64Encode("test", &buffer); + try std.testing.expectEqualStrings("dGVzdA==", result); +} diff --git a/src/telegram.zig b/src/telegram.zig new file mode 100644 index 0000000..7e7d94a --- /dev/null +++ b/src/telegram.zig @@ -0,0 +1,106 @@ +//! Cliente Telegram Bot API +//! +//! Envía mensajes a través de la API de bots de Telegram. +//! Usa curl para las peticiones HTTP (más fiable que std.http para POST). +//! +//! Para obtener los valores necesarios: +//! 1. Habla con @BotFather en Telegram para crear un bot y obtener el token +//! 2. Habla con @userinfobot para obtener tu chat_id +//! +//! Ejemplo: +//! ```zig +//! try telegram.sendMessage(allocator, "123:ABC", "987654321", "Servicio caído!"); +//! ``` + +const std = @import("std"); + +/// Errores posibles durante el envío. +pub const TelegramError = error{ + /// No se pudo ejecutar curl. + CommandFailed, + /// La API rechazó la petición. + ApiError, +}; + +/// Envía un mensaje de texto a través de Telegram Bot API. +/// +/// Usa curl para la petición HTTP POST (más fiable para JSON). +pub fn sendMessage( + allocator: std.mem.Allocator, + bot_token: []const u8, + chat_id: []const u8, + message: []const u8, +) TelegramError!void { + // Construir URL + var url_buf: [256]u8 = undefined; + const url = std.fmt.bufPrint(&url_buf, "https://api.telegram.org/bot{s}/sendMessage", .{bot_token}) catch { + return TelegramError.CommandFailed; + }; + + // Construir JSON body + var body_buf: [2048]u8 = undefined; + const body = std.fmt.bufPrint(&body_buf, + \\{{"chat_id":"{s}","text":"{s}"}} + , .{ chat_id, message }) catch { + return TelegramError.CommandFailed; + }; + + // Ejecutar curl + const result = std.process.Child.run(.{ + .allocator = allocator, + .argv = &[_][]const u8{ + "curl", + "-s", + "-X", + "POST", + url, + "-H", + "Content-Type: application/json", + "-d", + body, + }, + }) catch { + return TelegramError.CommandFailed; + }; + + allocator.free(result.stdout); + allocator.free(result.stderr); + + if (result.term.Exited != 0) { + return TelegramError.CommandFailed; + } +} + +/// Envía una alerta formateada sobre servicios caídos. +pub fn sendAlert( + allocator: std.mem.Allocator, + bot_token: []const u8, + chat_id: []const u8, + failed_services: []const []const u8, +) TelegramError!void { + var msg_buf: [1024]u8 = undefined; + var msg_len: usize = 0; + + // Header + const header = "⚠️ ALERTA: Servicios caídos\\n\\n"; + @memcpy(msg_buf[0..header.len], header); + msg_len = header.len; + + // Lista de servicios + for (failed_services) |service| { + if (msg_len + service.len + 6 < msg_buf.len) { + // "• " + msg_buf[msg_len] = '-'; + msg_buf[msg_len + 1] = ' '; + msg_len += 2; + @memcpy(msg_buf[msg_len..][0..service.len], service); + msg_len += service.len; + // "\n" escapado para JSON + msg_buf[msg_len] = '\\'; + msg_buf[msg_len + 1] = 'n'; + msg_len += 2; + } + } + + return sendMessage(allocator, bot_token, chat_id, msg_buf[0..msg_len]); +}