diff --git a/config/i18n/de/common.php b/config/i18n/de/common.php index 174339044..90d2215ce 100644 --- a/config/i18n/de/common.php +++ b/config/i18n/de/common.php @@ -326,7 +326,7 @@ 'ui.base.admin.licensing' => 'Lizenzierung', 'ui.base.admin.bbs_settings' => 'BBS-Einstellungen', 'ui.base.admin.appearance' => 'Aussehen', - 'ui.base.admin.binkp_configuration' => 'BinkP-Konfiguration', + 'ui.base.admin.binkp_configuration' => 'BinkP-Uplinks', 'ui.base.admin.template_editor' => 'Template Bearbeitenor', 'ui.base.admin.i18n_overrides' => 'Language Overrides', 'ui.base.admin.docs' => 'Dokumentation', @@ -3326,8 +3326,25 @@ 'ui.binkp.uplinks' => 'Uplinks', 'ui.binkp.status_tab' => 'Status', 'ui.binkp.uplinks_tab' => 'Uplinks', - 'ui.binkp.queues_tab' => 'Warteschlangen', - 'ui.binkp.kept_packets_tab' => 'Kept Packets', + 'ui.binkp.queues_tab' => 'Uplink-Warteschlangen', + 'ui.binkp.hub_outbound_tab' => 'Downlink-Warteschlange', + 'ui.binkp.hub_outbound_heading' => 'Downlink-Ausgangswarteschlange', + 'ui.binkp.hub_outbound.downlink' => 'Downlink', + 'ui.binkp.hub_outbound.type' => 'Typ', + 'ui.binkp.hub_outbound.size' => 'Größe', + 'ui.binkp.hub_outbound.attempts' => 'Versuche', + 'ui.binkp.hub_outbound.created' => 'Erstellt', + 'ui.binkp.hub_outbound.sent_next' => 'Gesendet / Nächster Versuch', + 'ui.binkp.hub_outbound.error' => 'Fehler', + 'ui.binkp.hub_outbound.empty' => 'Keine Downlink-Pakete in der Warteschlange', + 'ui.binkp.hub_outbound.inspect' => 'Untersuchen', + 'ui.binkp.hub_outbound.load_failed' => 'Downlink-Warteschlange konnte nicht geladen werden', + 'ui.binkp.hub_outbound.tic_fields' => 'TIC-Felder', + 'ui.binkp.hub_outbound.tic_area' => 'Bereich', + 'ui.binkp.hub_outbound.tic_file' => 'Datei', + 'ui.binkp.hub_outbound.tic_desc' => 'Beschreibung', + 'ui.binkp.hub_outbound.tic_ldesc' => 'Ausführliche Beschreibung', + 'ui.binkp.kept_packets_tab' => 'Aufbewahrte Uplink-Pakete', 'ui.binkp.kept_packets_locked' => 'Diese Funktion erfordert eine registrierte Lizenz.', 'ui.binkp.kept_packets_register' => 'Register to unlock', 'ui.binkp.logs_tab' => 'Logs', @@ -5342,4 +5359,58 @@ 'ui.echoareas_import.rule_na_comments' => 'Zeilen, die mit % oder ; beginnen, werden als Kommentare behandelt und übersprungen.', 'ui.echoareas_import.error_choose_na' => 'Bitte wähle eine .NA-Datei zum Importieren aus.', 'ui.echoareas_import.error_open_na' => 'Die hochgeladene .NA-Datei konnte nicht geöffnet werden.', + 'ui.base.admin.hub_nodes' => 'BinkP-Downlinks', + 'ui.admin.hub_nodes.page_title' => 'Downlinks', + 'ui.admin.hub_nodes.heading' => 'Downlinks', + 'ui.admin.hub_nodes.intro' => 'Verwalte untergeordnete FTN-Knoten und Points, an die diese BBS Echomail verteilt.', + 'ui.admin.hub_nodes.add_node' => 'Downlink hinzufügen', + 'ui.admin.hub_nodes.edit_node' => 'Downlink bearbeiten', + 'ui.admin.hub_nodes.type' => 'Typ', + 'ui.admin.hub_nodes.type_node' => 'Knoten', + 'ui.admin.hub_nodes.type_point' => 'Point', + 'ui.admin.hub_nodes.address' => 'Adresse', + 'ui.admin.hub_nodes.boss_address' => 'Boss-Adresse', + 'ui.admin.hub_nodes.point_number' => 'Point-Nummer', + 'ui.admin.hub_nodes.name' => 'Name', + 'ui.admin.hub_nodes.sysop_name' => 'Sysop-Name', + 'ui.admin.hub_nodes.session_password' => 'Sitzungspasswort', + 'ui.admin.hub_nodes.session_password_help' => 'Wird verwendet, um diesen untergeordneten Knoten zu authentifizieren, wenn er sich mit uns verbindet. Leer lassen, um das vorhandene Passwort beizubehalten.', + 'ui.admin.hub_nodes.packet_password' => 'Paketpasswort', + 'ui.admin.hub_nodes.areafix_password' => 'AreaFix-Passwort', + 'ui.admin.hub_nodes.areafix_password_help' => 'Passwort, das dieser Downlink zur Verwaltung seiner Bereichsabonnements per AreaFix-Netmail senden muss. Leer lassen, um das bestehende Passwort zu behalten; unbesetzt lassen, um AreaFix fur diesen Downlink zu deaktivieren.', + 'ui.admin.hub_nodes.filefix_password' => 'FileFix-Passwort', + 'ui.admin.hub_nodes.filefix_password_help' => 'Passwort, das dieser Downlink zur Verwaltung seiner Dateibereichsabonnements per FileFix-Netmail senden muss. Leer lassen, um das bestehende Passwort zu behalten; unbesetzt lassen, um FileFix fur diesen Downlink zu deaktivieren.', + 'ui.admin.hub_nodes.inet_host' => 'Internet-Host', + 'ui.admin.hub_nodes.inet_host_help' => 'Alternativer Host für Push-Zustellung. Points haben in der Regel keinen erreichbaren Host und werden nur abgeholt (Pull).', + 'ui.admin.hub_nodes.port' => 'Port', + 'ui.admin.hub_nodes.enabled' => 'Aktiviert', + 'ui.admin.hub_nodes.hold_mail' => 'Mail zurückhalten', + 'ui.admin.hub_nodes.compress_outbound' => 'Ausgang komprimieren', + 'ui.admin.hub_nodes.compress_outbound_help' => 'Gebündelte ausgehende Echomail/Netmail statt als Roh-Paket in ein ZIP-Arcmail-Bundle packen. Nur aktivieren, wenn die Gegenstelle eine weitere BinktermPHP-Instanz oder ein Mailer ist, der Bundle-Erweiterungen automatisch erkennt.', + 'ui.admin.hub_nodes.allow_inbound_echomail' => 'Echomail von diesem untergeordneten Knoten annehmen', + 'ui.admin.hub_nodes.allow_inbound_netmail' => 'Netmail von diesem untergeordneten Knoten annehmen', + 'ui.admin.hub_nodes.max_packet_kb' => 'Maximale Paketgröße (KB)', + 'ui.admin.hub_nodes.max_packet_kb_help' => '0 = unbegrenzt', + 'ui.admin.hub_nodes.queue_retention_days' => 'Warteschlangen-Aufbewahrung (Tage)', + 'ui.admin.hub_nodes.notes' => 'Notizen', + 'ui.admin.hub_nodes.flags' => 'Merkmale', + 'ui.admin.hub_nodes.queue' => 'Aufbewahrung', + 'ui.admin.hub_nodes.disabled' => 'Deaktiviert', + 'ui.admin.hub_nodes.days' => 'Tage', + 'ui.admin.hub_nodes.delete_confirm' => 'Diesen Downlink löschen?', + 'ui.admin.hub_nodes.load_failed' => 'Downlinks konnten nicht geladen werden', + 'ui.admin.hub_nodes.save_failed' => 'Downlink konnte nicht gespeichert werden', + 'ui.admin.hub_nodes.saved' => 'Downlink gespeichert', + 'ui.admin.hub_nodes.delete_failed' => 'Downlink konnte nicht gelöscht werden', + 'ui.admin.hub_nodes.deleted' => 'Downlink gelöscht', + 'ui.admin.hub_nodes.areas.title' => 'Bereichsabonnements', + 'ui.admin.hub_nodes.areas.none' => 'Keine Echobereiche gefunden', + 'ui.admin.hub_nodes.areas.load_failed' => 'Bereichsabonnements konnten nicht geladen werden', + 'ui.admin.hub_nodes.areas.save_failed' => 'Bereichsabonnements konnten nicht gespeichert werden', + 'ui.admin.hub_nodes.areas.saved' => 'Bereichsabonnements gespeichert', + 'ui.admin.hub_nodes.fileareas.title' => 'Dateibereichsabonnements', + 'ui.admin.hub_nodes.fileareas.none' => 'Keine Dateibereiche gefunden', + 'ui.admin.hub_nodes.fileareas.load_failed' => 'Dateibereichsabonnements konnten nicht geladen werden', + 'ui.admin.hub_nodes.fileareas.save_failed' => 'Dateibereichsabonnements konnten nicht gespeichert werden', + 'ui.admin.hub_nodes.fileareas.saved' => 'Dateibereichsabonnements gespeichert', ]; diff --git a/config/i18n/de/errors.php b/config/i18n/de/errors.php index ea10624a7..7bef511da 100644 --- a/config/i18n/de/errors.php +++ b/config/i18n/de/errors.php @@ -300,6 +300,8 @@ 'errors.binkp.kept_packets.license_required' => 'Ansehening packet files requires registration', 'errors.binkp.kept_packets.inspect_failed' => 'Failed to inspect packet', 'errors.binkp.queue.inspect_failed' => 'Failed to inspect queue packet', + 'errors.binkp.hub_outbound.list_failed' => 'Downlink-Warteschlange konnte nicht geladen werden', + 'errors.binkp.hub_outbound.invalid_id' => 'Ungültige Warteschlangen-ID', 'errors.binkp.uplink.address_hostname_required' => 'Hinzufügenress and hostname sind erforderlich', 'errors.binkp.uplink.address_required' => 'Uplink address ist erforderlich', 'errors.messages.forward_email.email_required' => 'An email address ist erforderlich', @@ -769,4 +771,12 @@ 'errors.admin.appearance.term_menu_keys.duplicate_key' => 'Jede Menütaste muss eindeutig sein', 'errors.admin.appearance.term_menu_keys.quit_required' => 'Für Beenden muss eine Taste zugewiesen sein', 'errors.admin.appearance.term_menu_keys.save_failed' => 'Die Menütasteneinstellungen konnten nicht gespeichert werden', + 'errors.admin.hub_nodes.load_failed' => 'Downlinks konnten nicht geladen werden', + 'errors.admin.hub_nodes.save_failed' => 'Downlink konnte nicht gespeichert werden', + 'errors.admin.hub_nodes.delete_failed' => 'Downlink konnte nicht gelöscht werden', + 'errors.admin.hub_nodes.areas_load_failed' => 'Bereichsabonnements konnten nicht geladen werden', + 'errors.admin.hub_nodes.areas_save_failed' => 'Bereichsabonnements konnten nicht gespeichert werden', + 'errors.admin.hub_nodes.fileareas_load_failed' => 'Dateibereichsabonnements konnten nicht geladen werden', + 'errors.admin.hub_nodes.fileareas_save_failed' => 'Dateibereichsabonnements konnten nicht gespeichert werden', + 'errors.admin.hub_nodes.next_point_failed' => 'Die nächste Point-Nummer konnte nicht ermittelt werden', ]; diff --git a/config/i18n/en/common.php b/config/i18n/en/common.php index a7fcb1654..c0a2399fa 100644 --- a/config/i18n/en/common.php +++ b/config/i18n/en/common.php @@ -326,7 +326,7 @@ 'ui.base.admin.licensing' => 'Licensing', 'ui.base.admin.bbs_settings' => 'BBS Settings', 'ui.base.admin.appearance' => 'Appearance', - 'ui.base.admin.binkp_configuration' => 'Binkp Configuration', + 'ui.base.admin.binkp_configuration' => 'BinkP Uplinks', 'ui.base.admin.template_editor' => 'Template Editor', 'ui.base.admin.i18n_overrides' => 'Language Overrides', 'ui.base.admin.docs' => 'Documentation', @@ -3346,8 +3346,25 @@ 'ui.binkp.uplinks' => 'Uplinks', 'ui.binkp.status_tab' => 'Status', 'ui.binkp.uplinks_tab' => 'Uplinks', - 'ui.binkp.queues_tab' => 'Queues', - 'ui.binkp.kept_packets_tab' => 'Kept Packets', + 'ui.binkp.queues_tab' => 'Uplink Queues', + 'ui.binkp.hub_outbound_tab' => 'Downlink Queue', + 'ui.binkp.hub_outbound_heading' => 'Downlink Outbound Queue', + 'ui.binkp.hub_outbound.downlink' => 'Downlink', + 'ui.binkp.hub_outbound.type' => 'Type', + 'ui.binkp.hub_outbound.size' => 'Size', + 'ui.binkp.hub_outbound.attempts' => 'Attempts', + 'ui.binkp.hub_outbound.created' => 'Created', + 'ui.binkp.hub_outbound.sent_next' => 'Sent / Next Attempt', + 'ui.binkp.hub_outbound.error' => 'Error', + 'ui.binkp.hub_outbound.empty' => 'No queued downlink packets', + 'ui.binkp.hub_outbound.inspect' => 'Inspect', + 'ui.binkp.hub_outbound.load_failed' => 'Failed to load downlink queue', + 'ui.binkp.hub_outbound.tic_fields' => 'TIC Fields', + 'ui.binkp.hub_outbound.tic_area' => 'Area', + 'ui.binkp.hub_outbound.tic_file' => 'File', + 'ui.binkp.hub_outbound.tic_desc' => 'Description', + 'ui.binkp.hub_outbound.tic_ldesc' => 'Long Description', + 'ui.binkp.kept_packets_tab' => 'Uplink Kept Packets', 'ui.binkp.kept_packets_locked' => 'This feature requires a registered license.', 'ui.binkp.kept_packets_register' => 'Register to unlock', 'ui.binkp.logs_tab' => 'Logs', @@ -5235,6 +5252,60 @@ 'ui.admin.networks.deleted' => 'Network deleted', 'ui.admin.networks.change_domain_failed' => 'Failed to change domain', 'ui.admin.networks.domain_changed' => 'Domain changed', + 'ui.base.admin.hub_nodes' => 'BinkP Downlinks', + 'ui.admin.hub_nodes.page_title' => 'Downlinks', + 'ui.admin.hub_nodes.heading' => 'Downlinks', + 'ui.admin.hub_nodes.intro' => 'Manage subordinate FTN nodes and points this BBS distributes echomail to.', + 'ui.admin.hub_nodes.add_node' => 'Add Downlink', + 'ui.admin.hub_nodes.edit_node' => 'Edit Downlink', + 'ui.admin.hub_nodes.type' => 'Type', + 'ui.admin.hub_nodes.type_node' => 'Node', + 'ui.admin.hub_nodes.type_point' => 'Point', + 'ui.admin.hub_nodes.address' => 'Address', + 'ui.admin.hub_nodes.boss_address' => 'Boss Address', + 'ui.admin.hub_nodes.point_number' => 'Point Number', + 'ui.admin.hub_nodes.name' => 'Name', + 'ui.admin.hub_nodes.sysop_name' => 'Sysop Name', + 'ui.admin.hub_nodes.session_password' => 'Session Password', + 'ui.admin.hub_nodes.session_password_help' => 'Used to authenticate this subordinate when it connects to us. Leave blank to keep the existing password.', + 'ui.admin.hub_nodes.packet_password' => 'Packet Password', + 'ui.admin.hub_nodes.areafix_password' => 'AreaFix Password', + 'ui.admin.hub_nodes.areafix_password_help' => 'Password this subordinate must send to manage its echo area subscriptions via AreaFix netmail. Leave blank to keep the existing password; leave unset to disable AreaFix for this subordinate.', + 'ui.admin.hub_nodes.filefix_password' => 'FileFix Password', + 'ui.admin.hub_nodes.filefix_password_help' => 'Password this subordinate must send to manage its file area subscriptions via FileFix netmail. Leave blank to keep the existing password; leave unset to disable FileFix for this subordinate.', + 'ui.admin.hub_nodes.inet_host' => 'Internet Host', + 'ui.admin.hub_nodes.inet_host_help' => 'Override host for push delivery. Points typically have no routable host and are pull-only.', + 'ui.admin.hub_nodes.port' => 'Port', + 'ui.admin.hub_nodes.enabled' => 'Enabled', + 'ui.admin.hub_nodes.hold_mail' => 'Hold Mail', + 'ui.admin.hub_nodes.compress_outbound' => 'Compress Outbound', + 'ui.admin.hub_nodes.compress_outbound_help' => 'Pack bundled outbound echomail/netmail into a ZIP arcmail bundle instead of a raw packet. Only enable this if the downlink is another BinktermPHP instance or a mailer that auto-detects bundle extensions.', + 'ui.admin.hub_nodes.allow_inbound_echomail' => 'Accept Echomail From This Subordinate', + 'ui.admin.hub_nodes.allow_inbound_netmail' => 'Accept Netmail From This Subordinate', + 'ui.admin.hub_nodes.max_packet_kb' => 'Max Packet Size (KB)', + 'ui.admin.hub_nodes.max_packet_kb_help' => '0 = unlimited', + 'ui.admin.hub_nodes.queue_retention_days' => 'Queue Retention (Days)', + 'ui.admin.hub_nodes.notes' => 'Notes', + 'ui.admin.hub_nodes.flags' => 'Flags', + 'ui.admin.hub_nodes.queue' => 'Retention', + 'ui.admin.hub_nodes.disabled' => 'Disabled', + 'ui.admin.hub_nodes.days' => 'days', + 'ui.admin.hub_nodes.delete_confirm' => 'Delete this downlink?', + 'ui.admin.hub_nodes.load_failed' => 'Failed to load downlinks', + 'ui.admin.hub_nodes.save_failed' => 'Failed to save downlink', + 'ui.admin.hub_nodes.saved' => 'Downlink saved', + 'ui.admin.hub_nodes.delete_failed' => 'Failed to delete downlink', + 'ui.admin.hub_nodes.deleted' => 'Downlink deleted', + 'ui.admin.hub_nodes.areas.title' => 'Area Subscriptions', + 'ui.admin.hub_nodes.areas.none' => 'No echoareas found', + 'ui.admin.hub_nodes.areas.load_failed' => 'Failed to load area subscriptions', + 'ui.admin.hub_nodes.areas.save_failed' => 'Failed to save area subscriptions', + 'ui.admin.hub_nodes.areas.saved' => 'Area subscriptions saved', + 'ui.admin.hub_nodes.fileareas.title' => 'File Area Subscriptions', + 'ui.admin.hub_nodes.fileareas.none' => 'No file areas found', + 'ui.admin.hub_nodes.fileareas.load_failed' => 'Failed to load file area subscriptions', + 'ui.admin.hub_nodes.fileareas.save_failed' => 'Failed to save file area subscriptions', + 'ui.admin.hub_nodes.fileareas.saved' => 'File area subscriptions saved', 'ui.settings.tab.pgp' => 'PGP', 'ui.settings.pgp.heading' => 'PGP Keys', 'ui.settings.pgp.help' => 'Upload public keys, generate BBS-managed private keys, and choose which public key is your primary listing on the keyserver.', diff --git a/config/i18n/en/errors.php b/config/i18n/en/errors.php index 75d394b99..643908ae1 100644 --- a/config/i18n/en/errors.php +++ b/config/i18n/en/errors.php @@ -301,6 +301,8 @@ 'errors.binkp.kept_packets.license_required' => 'Viewing packet files requires registration', 'errors.binkp.kept_packets.inspect_failed' => 'Failed to inspect packet', 'errors.binkp.queue.inspect_failed' => 'Failed to inspect queue packet', + 'errors.binkp.hub_outbound.list_failed' => 'Failed to load downlink queue', + 'errors.binkp.hub_outbound.invalid_id' => 'Invalid queue item id', 'errors.binkp.uplink.address_hostname_required' => 'Address and hostname are required', 'errors.binkp.uplink.address_required' => 'Uplink address is required', 'errors.messages.forward_email.email_required' => 'An email address is required', @@ -747,6 +749,14 @@ 'errors.admin.networks.change_domain_failed' => 'Failed to change domain', 'errors.admin.networks.delete_in_use' => 'Network is in use', 'errors.admin.networks.delete_failed' => 'Failed to delete network', + 'errors.admin.hub_nodes.load_failed' => 'Failed to load downlinks', + 'errors.admin.hub_nodes.save_failed' => 'Failed to save downlink', + 'errors.admin.hub_nodes.delete_failed' => 'Failed to delete downlink', + 'errors.admin.hub_nodes.areas_load_failed' => 'Failed to load area subscriptions', + 'errors.admin.hub_nodes.areas_save_failed' => 'Failed to save area subscriptions', + 'errors.admin.hub_nodes.fileareas_load_failed' => 'Failed to load file area subscriptions', + 'errors.admin.hub_nodes.fileareas_save_failed' => 'Failed to save file area subscriptions', + 'errors.admin.hub_nodes.next_point_failed' => 'Failed to determine next point number', 'errors.meshcore.invalid_node_id' => 'Node ID must be 12 or 64 lowercase hex characters.', 'errors.meshcore.contact_exists' => 'A contact with this node ID already exists.', 'errors.meshcore.not_found' => 'Contact not found.', diff --git a/config/i18n/es/common.php b/config/i18n/es/common.php index cd1be374c..07fccc2c0 100644 --- a/config/i18n/es/common.php +++ b/config/i18n/es/common.php @@ -326,7 +326,7 @@ 'ui.base.admin.licensing' => 'Licencias', 'ui.base.admin.bbs_settings' => 'Configuracion del BBS', 'ui.base.admin.appearance' => 'Apariencia', - 'ui.base.admin.binkp_configuration' => 'Configuracion de Binkp', + 'ui.base.admin.binkp_configuration' => 'Uplinks de BinkP', 'ui.base.admin.template_editor' => 'Editor de plantillas', 'ui.base.admin.i18n_overrides' => 'Ajustes de idioma', 'ui.base.admin.docs' => 'Documentación', @@ -3329,8 +3329,25 @@ 'ui.binkp.uplinks' => 'Uplinks', 'ui.binkp.status_tab' => 'Estado', 'ui.binkp.uplinks_tab' => 'Uplinks', - 'ui.binkp.queues_tab' => 'Colas', - 'ui.binkp.kept_packets_tab' => 'Paquetes guardados', + 'ui.binkp.queues_tab' => 'Colas de Uplink', + 'ui.binkp.hub_outbound_tab' => 'Cola de Downlinks', + 'ui.binkp.hub_outbound_heading' => 'Cola de Salida de Downlinks', + 'ui.binkp.hub_outbound.downlink' => 'Downlink', + 'ui.binkp.hub_outbound.type' => 'Tipo', + 'ui.binkp.hub_outbound.size' => 'Tamano', + 'ui.binkp.hub_outbound.attempts' => 'Intentos', + 'ui.binkp.hub_outbound.created' => 'Creado', + 'ui.binkp.hub_outbound.sent_next' => 'Enviado / Proximo Intento', + 'ui.binkp.hub_outbound.error' => 'Error', + 'ui.binkp.hub_outbound.empty' => 'No hay paquetes de downlink en cola', + 'ui.binkp.hub_outbound.inspect' => 'Inspeccionar', + 'ui.binkp.hub_outbound.load_failed' => 'No se pudo cargar la cola de downlinks', + 'ui.binkp.hub_outbound.tic_fields' => 'Campos TIC', + 'ui.binkp.hub_outbound.tic_area' => 'Area', + 'ui.binkp.hub_outbound.tic_file' => 'Archivo', + 'ui.binkp.hub_outbound.tic_desc' => 'Descripcion', + 'ui.binkp.hub_outbound.tic_ldesc' => 'Descripcion larga', + 'ui.binkp.kept_packets_tab' => 'Paquetes de Uplink guardados', 'ui.binkp.kept_packets_locked' => 'Esta funcion requiere una licencia registrada.', 'ui.binkp.kept_packets_register' => 'Registrarse para desbloquear', 'ui.binkp.logs_tab' => 'Logs', @@ -5330,4 +5347,58 @@ 'ui.echoareas_import.rule_na_comments' => 'Las lineas que comienzan con % o ; se tratan como comentarios y se omiten.', 'ui.echoareas_import.error_choose_na' => 'Selecciona un archivo .NA para importar.', 'ui.echoareas_import.error_open_na' => 'No se pudo abrir el archivo .NA subido.', + 'ui.base.admin.hub_nodes' => 'Downlinks de BinkP', + 'ui.admin.hub_nodes.page_title' => 'Downlinks', + 'ui.admin.hub_nodes.heading' => 'Downlinks', + 'ui.admin.hub_nodes.intro' => 'Administra los nodos y points FTN subordinados a los que este BBS distribuye correo de eco.', + 'ui.admin.hub_nodes.add_node' => 'Agregar Downlink', + 'ui.admin.hub_nodes.edit_node' => 'Editar Downlink', + 'ui.admin.hub_nodes.type' => 'Tipo', + 'ui.admin.hub_nodes.type_node' => 'Nodo', + 'ui.admin.hub_nodes.type_point' => 'Point', + 'ui.admin.hub_nodes.address' => 'Direccion', + 'ui.admin.hub_nodes.boss_address' => 'Direccion del Boss', + 'ui.admin.hub_nodes.point_number' => 'Numero de Point', + 'ui.admin.hub_nodes.name' => 'Nombre', + 'ui.admin.hub_nodes.sysop_name' => 'Nombre del Sysop', + 'ui.admin.hub_nodes.session_password' => 'Contrasena de Sesion', + 'ui.admin.hub_nodes.session_password_help' => 'Se usa para autenticar a este subordinado cuando se conecta a nosotros. Dejar en blanco para mantener la contrasena actual.', + 'ui.admin.hub_nodes.packet_password' => 'Contrasena de Paquete', + 'ui.admin.hub_nodes.areafix_password' => 'Contrasena de AreaFix', + 'ui.admin.hub_nodes.areafix_password_help' => 'Contrasena que este downlink debe enviar para administrar sus suscripciones de areas de eco via netmail AreaFix. Dejar en blanco para conservar la contrasena existente; dejar sin definir para deshabilitar AreaFix para este downlink.', + 'ui.admin.hub_nodes.filefix_password' => 'Contrasena de FileFix', + 'ui.admin.hub_nodes.filefix_password_help' => 'Contrasena que este downlink debe enviar para administrar sus suscripciones de areas de archivos via netmail FileFix. Dejar en blanco para conservar la contrasena existente; dejar sin definir para deshabilitar FileFix para este downlink.', + 'ui.admin.hub_nodes.inet_host' => 'Host de Internet', + 'ui.admin.hub_nodes.inet_host_help' => 'Host alternativo para entrega push. Los points normalmente no tienen un host enrutable y solo reciben por pull.', + 'ui.admin.hub_nodes.port' => 'Puerto', + 'ui.admin.hub_nodes.enabled' => 'Habilitado', + 'ui.admin.hub_nodes.hold_mail' => 'Retener Correo', + 'ui.admin.hub_nodes.compress_outbound' => 'Comprimir Saliente', + 'ui.admin.hub_nodes.compress_outbound_help' => 'Empaquetar el echomail/netmail saliente combinado en un paquete ZIP arcmail en lugar de un paquete sin comprimir. Active esto solo si el enlace es otra instancia de BinktermPHP o un mailer que detecta automáticamente las extensiones de paquete.', + 'ui.admin.hub_nodes.allow_inbound_echomail' => 'Aceptar echomail de este subordinado', + 'ui.admin.hub_nodes.allow_inbound_netmail' => 'Aceptar netmail de este subordinado', + 'ui.admin.hub_nodes.max_packet_kb' => 'Tamano Maximo de Paquete (KB)', + 'ui.admin.hub_nodes.max_packet_kb_help' => '0 = sin limite', + 'ui.admin.hub_nodes.queue_retention_days' => 'Retencion de Cola (Dias)', + 'ui.admin.hub_nodes.notes' => 'Notas', + 'ui.admin.hub_nodes.flags' => 'Marcas', + 'ui.admin.hub_nodes.queue' => 'Retencion', + 'ui.admin.hub_nodes.disabled' => 'Deshabilitado', + 'ui.admin.hub_nodes.days' => 'dias', + 'ui.admin.hub_nodes.delete_confirm' => '¿Eliminar este downlink?', + 'ui.admin.hub_nodes.load_failed' => 'No se pudieron cargar los downlinks', + 'ui.admin.hub_nodes.save_failed' => 'No se pudo guardar el downlink', + 'ui.admin.hub_nodes.saved' => 'Downlink guardado', + 'ui.admin.hub_nodes.delete_failed' => 'No se pudo eliminar el downlink', + 'ui.admin.hub_nodes.deleted' => 'Downlink eliminado', + 'ui.admin.hub_nodes.areas.title' => 'Suscripciones de Areas', + 'ui.admin.hub_nodes.areas.none' => 'No se encontraron areas de eco', + 'ui.admin.hub_nodes.areas.load_failed' => 'No se pudieron cargar las suscripciones de areas', + 'ui.admin.hub_nodes.areas.save_failed' => 'No se pudieron guardar las suscripciones de areas', + 'ui.admin.hub_nodes.areas.saved' => 'Suscripciones de areas guardadas', + 'ui.admin.hub_nodes.fileareas.title' => 'Suscripciones de Areas de Archivos', + 'ui.admin.hub_nodes.fileareas.none' => 'No se encontraron areas de archivos', + 'ui.admin.hub_nodes.fileareas.load_failed' => 'No se pudieron cargar las suscripciones de areas de archivos', + 'ui.admin.hub_nodes.fileareas.save_failed' => 'No se pudieron guardar las suscripciones de areas de archivos', + 'ui.admin.hub_nodes.fileareas.saved' => 'Suscripciones de areas de archivos guardadas', ]; diff --git a/config/i18n/es/errors.php b/config/i18n/es/errors.php index 2b3928efb..db34f46c9 100644 --- a/config/i18n/es/errors.php +++ b/config/i18n/es/errors.php @@ -301,6 +301,8 @@ 'errors.binkp.kept_packets.license_required' => 'Ver paquetes guardados requiere una licencia registrada', 'errors.binkp.kept_packets.inspect_failed' => 'No se pudo inspeccionar el paquete', 'errors.binkp.queue.inspect_failed' => 'No se pudo inspeccionar el paquete de la cola', + 'errors.binkp.hub_outbound.list_failed' => 'No se pudo cargar la cola de downlinks', + 'errors.binkp.hub_outbound.invalid_id' => 'ID de elemento de cola invalido', 'errors.binkp.uplink.address_hostname_required' => 'Se requieren direccion y hostname', 'errors.binkp.uplink.address_required' => 'Se requiere la direccion del uplink', 'errors.messages.forward_email.email_required' => 'Se requiere una direccion de correo', @@ -767,4 +769,12 @@ 'errors.admin.appearance.term_menu_keys.duplicate_key' => 'Cada tecla del menu debe ser unica', 'errors.admin.appearance.term_menu_keys.quit_required' => 'Debe asignarse una tecla a Salir', 'errors.admin.appearance.term_menu_keys.save_failed' => 'No se pudo guardar la configuracion de las teclas del menu', + 'errors.admin.hub_nodes.load_failed' => 'No se pudieron cargar los downlinks', + 'errors.admin.hub_nodes.save_failed' => 'No se pudo guardar el downlink', + 'errors.admin.hub_nodes.delete_failed' => 'No se pudo eliminar el downlink', + 'errors.admin.hub_nodes.areas_load_failed' => 'No se pudieron cargar las suscripciones de areas', + 'errors.admin.hub_nodes.areas_save_failed' => 'No se pudieron guardar las suscripciones de areas', + 'errors.admin.hub_nodes.fileareas_load_failed' => 'No se pudieron cargar las suscripciones de areas de archivos', + 'errors.admin.hub_nodes.fileareas_save_failed' => 'No se pudieron guardar las suscripciones de areas de archivos', + 'errors.admin.hub_nodes.next_point_failed' => 'No se pudo determinar el siguiente numero de point', ]; diff --git a/config/i18n/fr/common.php b/config/i18n/fr/common.php index c4b5beccf..ae8c1a1de 100644 --- a/config/i18n/fr/common.php +++ b/config/i18n/fr/common.php @@ -290,7 +290,7 @@ 'ui.base.admin.economy_viewer' => 'Visualiseur d\'économie', 'ui.base.admin.bbs_settings' => 'Paramètres BBS', 'ui.base.admin.appearance' => 'Apparence', - 'ui.base.admin.binkp_configuration' => 'Configuration Binkp', + 'ui.base.admin.binkp_configuration' => 'Uplinks BinkP', 'ui.base.admin.template_editor' => 'Éditeur de modèles', 'ui.base.admin.i18n_overrides' => 'Substitutions de langue', 'ui.base.admin.docs' => 'Documentation', @@ -2776,7 +2776,24 @@ 'ui.binkp.uplinks' => 'Uplinks', 'ui.binkp.status_tab' => 'État', 'ui.binkp.uplinks_tab' => 'Uplinks', - 'ui.binkp.queues_tab' => 'Files d\'attente', + 'ui.binkp.queues_tab' => 'Files d\'attente Uplink', + 'ui.binkp.hub_outbound_tab' => 'File des Downlinks', + 'ui.binkp.hub_outbound_heading' => 'File de sortie des Downlinks', + 'ui.binkp.hub_outbound.downlink' => 'Downlink', + 'ui.binkp.hub_outbound.type' => 'Type', + 'ui.binkp.hub_outbound.size' => 'Taille', + 'ui.binkp.hub_outbound.attempts' => 'Tentatives', + 'ui.binkp.hub_outbound.created' => 'Cree', + 'ui.binkp.hub_outbound.sent_next' => 'Envoye / Prochaine tentative', + 'ui.binkp.hub_outbound.error' => 'Erreur', + 'ui.binkp.hub_outbound.empty' => 'Aucun paquet de downlink en file d\'attente', + 'ui.binkp.hub_outbound.inspect' => 'Inspecter', + 'ui.binkp.hub_outbound.load_failed' => 'Echec du chargement de la file des downlinks', + 'ui.binkp.hub_outbound.tic_fields' => 'Champs TIC', + 'ui.binkp.hub_outbound.tic_area' => 'Zone', + 'ui.binkp.hub_outbound.tic_file' => 'Fichier', + 'ui.binkp.hub_outbound.tic_desc' => 'Description', + 'ui.binkp.hub_outbound.tic_ldesc' => 'Description longue', 'ui.binkp.logs_tab' => 'Journaux', 'ui.binkp.system_information' => 'Informations système', 'ui.binkp.loading_system_information' => 'Chargement des informations système...', @@ -4493,7 +4510,7 @@ 'ui.binkp.kept_packets_empty' => 'Aucun paquet conservé trouvé.', 'ui.binkp.kept_packets_locked' => 'Cette fonctionnalité nécessite une licence enregistrée.', 'ui.binkp.kept_packets_register' => 'S\'enregistrer pour débloquer', - 'ui.binkp.kept_packets_tab' => 'Paquets conservés', + 'ui.binkp.kept_packets_tab' => 'Paquets Uplink conservés', 'ui.binkp.kept_packets_total' => '{count} paquet(s)', 'ui.binkp.log_matches' => '{count} / {total} lignes', 'ui.binkp.log_search_legend_context' => 'Autres lignes de la même session (même PID)', @@ -5237,4 +5254,58 @@ 'ui.keyserver.add_modal_node_address_help' => 'Adresse FTN du système BBS (p. ex. 1:234/567)', 'ui.keyserver.add_modal_submit' => 'Ajouter au carnet d\'adresses', 'ui.keyserver.pgp_key_added' => 'Contact ajouté au carnet d\'adresses avec la clé PGP.', + 'ui.base.admin.hub_nodes' => 'Downlinks BinkP', + 'ui.admin.hub_nodes.page_title' => 'Downlinks', + 'ui.admin.hub_nodes.heading' => 'Downlinks', + 'ui.admin.hub_nodes.intro' => 'Gerer les noeuds et points FTN subordonnes vers lesquels ce BBS distribue l\'echomail.', + 'ui.admin.hub_nodes.add_node' => 'Ajouter un downlink', + 'ui.admin.hub_nodes.edit_node' => 'Modifier le downlink', + 'ui.admin.hub_nodes.type' => 'Type', + 'ui.admin.hub_nodes.type_node' => 'Noeud', + 'ui.admin.hub_nodes.type_point' => 'Point', + 'ui.admin.hub_nodes.address' => 'Adresse', + 'ui.admin.hub_nodes.boss_address' => 'Adresse du boss', + 'ui.admin.hub_nodes.point_number' => 'Numero de point', + 'ui.admin.hub_nodes.name' => 'Nom', + 'ui.admin.hub_nodes.sysop_name' => 'Nom du sysop', + 'ui.admin.hub_nodes.session_password' => 'Mot de passe de session', + 'ui.admin.hub_nodes.session_password_help' => 'Utilise pour authentifier ce subordonne lorsqu\'il se connecte a nous. Laisser vide pour conserver le mot de passe existant.', + 'ui.admin.hub_nodes.packet_password' => 'Mot de passe de paquet', + 'ui.admin.hub_nodes.areafix_password' => 'Mot de passe AreaFix', + 'ui.admin.hub_nodes.areafix_password_help' => 'Mot de passe que ce downlink doit envoyer pour gerer ses abonnements aux zones d\'echo via netmail AreaFix. Laisser vide pour conserver le mot de passe existant ; laisser non defini pour desactiver AreaFix pour ce downlink.', + 'ui.admin.hub_nodes.filefix_password' => 'Mot de passe FileFix', + 'ui.admin.hub_nodes.filefix_password_help' => 'Mot de passe que ce downlink doit envoyer pour gerer ses abonnements aux zones de fichiers via netmail FileFix. Laisser vide pour conserver le mot de passe existant ; laisser non defini pour desactiver FileFix pour ce downlink.', + 'ui.admin.hub_nodes.inet_host' => 'Hote Internet', + 'ui.admin.hub_nodes.inet_host_help' => 'Hote de remplacement pour la livraison push. Les points n\'ont generalement pas d\'hote routable et ne recoivent que par pull.', + 'ui.admin.hub_nodes.port' => 'Port', + 'ui.admin.hub_nodes.enabled' => 'Active', + 'ui.admin.hub_nodes.hold_mail' => 'Retenir le courrier', + 'ui.admin.hub_nodes.compress_outbound' => 'Compresser les sortants', + 'ui.admin.hub_nodes.compress_outbound_help' => 'Empaqueter l\'echomail/netmail sortant groupé dans une archive ZIP arcmail au lieu d\'un paquet brut. N\'activez ceci que si le correspondant est une autre instance BinktermPHP ou un logiciel de messagerie qui détecte automatiquement les extensions de bundle.', + 'ui.admin.hub_nodes.allow_inbound_echomail' => 'Accepter l\'echomail de ce subordonne', + 'ui.admin.hub_nodes.allow_inbound_netmail' => 'Accepter le netmail de ce subordonne', + 'ui.admin.hub_nodes.max_packet_kb' => 'Taille maximale de paquet (Ko)', + 'ui.admin.hub_nodes.max_packet_kb_help' => '0 = illimite', + 'ui.admin.hub_nodes.queue_retention_days' => 'Retention de la file (jours)', + 'ui.admin.hub_nodes.notes' => 'Notes', + 'ui.admin.hub_nodes.flags' => 'Indicateurs', + 'ui.admin.hub_nodes.queue' => 'Retention', + 'ui.admin.hub_nodes.disabled' => 'Desactive', + 'ui.admin.hub_nodes.days' => 'jours', + 'ui.admin.hub_nodes.delete_confirm' => 'Supprimer ce downlink ?', + 'ui.admin.hub_nodes.load_failed' => 'Echec du chargement des downlinks', + 'ui.admin.hub_nodes.save_failed' => 'Echec de l\'enregistrement du downlink', + 'ui.admin.hub_nodes.saved' => 'Downlink enregistre', + 'ui.admin.hub_nodes.delete_failed' => 'Echec de la suppression du downlink', + 'ui.admin.hub_nodes.deleted' => 'Downlink supprime', + 'ui.admin.hub_nodes.areas.title' => 'Abonnements aux zones', + 'ui.admin.hub_nodes.areas.none' => 'Aucune zone d\'echo trouvee', + 'ui.admin.hub_nodes.areas.load_failed' => 'Echec du chargement des abonnements aux zones', + 'ui.admin.hub_nodes.areas.save_failed' => 'Echec de l\'enregistrement des abonnements aux zones', + 'ui.admin.hub_nodes.areas.saved' => 'Abonnements aux zones enregistres', + 'ui.admin.hub_nodes.fileareas.title' => 'Abonnements aux zones de fichiers', + 'ui.admin.hub_nodes.fileareas.none' => 'Aucune zone de fichiers trouvee', + 'ui.admin.hub_nodes.fileareas.load_failed' => 'Echec du chargement des abonnements aux zones de fichiers', + 'ui.admin.hub_nodes.fileareas.save_failed' => 'Echec de l\'enregistrement des abonnements aux zones de fichiers', + 'ui.admin.hub_nodes.fileareas.saved' => 'Abonnements aux zones de fichiers enregistres', ]; diff --git a/config/i18n/fr/errors.php b/config/i18n/fr/errors.php index f169f7f33..4e65b21cd 100644 --- a/config/i18n/fr/errors.php +++ b/config/i18n/fr/errors.php @@ -534,6 +534,8 @@ 'errors.binkp.kept_packets.failed' => 'Échec du chargement des paquets conservés', 'errors.binkp.kept_packets.inspect_failed' => 'Échec de l\'inspection du paquet', 'errors.binkp.queue.inspect_failed' => 'Échec de l\'inspection du paquet en file d\'attente', + 'errors.binkp.hub_outbound.list_failed' => 'Echec du chargement de la file des downlinks', + 'errors.binkp.hub_outbound.invalid_id' => 'Identifiant d\'element de file invalide', 'errors.binkp.kept_packets.invalid_type' => 'Le type doit être inbound ou outbound', 'errors.binkp.kept_packets.license_required' => 'La consultation des paquets conservés nécessite une licence enregistrée', @@ -722,6 +724,14 @@ 'errors.pgp.passphrase_too_short' => 'Utilisez une phrase de passe PGP plus longue.', 'errors.pgp.passphrase_mismatch' => 'La confirmation de la phrase de passe ne correspond pas.', 'errors.pgp.generation_failed' => 'Impossible de generer la cle PGP geree.', + 'errors.admin.hub_nodes.load_failed' => 'Echec du chargement des downlinks', + 'errors.admin.hub_nodes.save_failed' => 'Echec de l\'enregistrement du downlink', + 'errors.admin.hub_nodes.delete_failed' => 'Echec de la suppression du downlink', + 'errors.admin.hub_nodes.areas_load_failed' => 'Echec du chargement des abonnements aux zones', + 'errors.admin.hub_nodes.areas_save_failed' => 'Echec de l\'enregistrement des abonnements aux zones', + 'errors.admin.hub_nodes.fileareas_load_failed' => 'Echec du chargement des abonnements aux zones de fichiers', + 'errors.admin.hub_nodes.fileareas_save_failed' => 'Echec de l\'enregistrement des abonnements aux zones de fichiers', + 'errors.admin.hub_nodes.next_point_failed' => 'Impossible de determiner le prochain numero de point', ]; diff --git a/config/i18n/it/common.php b/config/i18n/it/common.php index c7be51123..54553a5f1 100644 --- a/config/i18n/it/common.php +++ b/config/i18n/it/common.php @@ -326,7 +326,7 @@ 'ui.base.admin.licensing' => 'Licenze', 'ui.base.admin.bbs_settings' => 'Impostazioni BBS', 'ui.base.admin.appearance' => 'Aspetto', - 'ui.base.admin.binkp_configuration' => 'Configurazione Binkp', + 'ui.base.admin.binkp_configuration' => 'Uplink BinkP', 'ui.base.admin.template_editor' => 'Editor template', 'ui.base.admin.i18n_overrides' => 'Override lingua', 'ui.base.admin.docs' => 'Documentazione', @@ -3328,8 +3328,25 @@ 'ui.binkp.uplinks' => 'Uplink', 'ui.binkp.status_tab' => 'Stato', 'ui.binkp.uplinks_tab' => 'Uplink', - 'ui.binkp.queues_tab' => 'Code', - 'ui.binkp.kept_packets_tab' => 'Pacchetti conservati', + 'ui.binkp.queues_tab' => 'Code Uplink', + 'ui.binkp.hub_outbound_tab' => 'Coda Downlink', + 'ui.binkp.hub_outbound_heading' => 'Coda In Uscita Downlink', + 'ui.binkp.hub_outbound.downlink' => 'Downlink', + 'ui.binkp.hub_outbound.type' => 'Tipo', + 'ui.binkp.hub_outbound.size' => 'Dimensione', + 'ui.binkp.hub_outbound.attempts' => 'Tentativi', + 'ui.binkp.hub_outbound.created' => 'Creato', + 'ui.binkp.hub_outbound.sent_next' => 'Inviato / Prossimo Tentativo', + 'ui.binkp.hub_outbound.error' => 'Errore', + 'ui.binkp.hub_outbound.empty' => 'Nessun pacchetto downlink in coda', + 'ui.binkp.hub_outbound.inspect' => 'Ispeziona', + 'ui.binkp.hub_outbound.load_failed' => 'Impossibile caricare la coda downlink', + 'ui.binkp.hub_outbound.tic_fields' => 'Campi TIC', + 'ui.binkp.hub_outbound.tic_area' => 'Area', + 'ui.binkp.hub_outbound.tic_file' => 'File', + 'ui.binkp.hub_outbound.tic_desc' => 'Descrizione', + 'ui.binkp.hub_outbound.tic_ldesc' => 'Descrizione estesa', + 'ui.binkp.kept_packets_tab' => 'Pacchetti Uplink conservati', 'ui.binkp.kept_packets_locked' => 'Questa funzionalità richiede una licenza registrata.', 'ui.binkp.kept_packets_register' => 'Registrati per sbloccare', 'ui.binkp.logs_tab' => 'Log', @@ -5327,4 +5344,58 @@ 'ui.echoareas_import.rule_na_comments' => 'Le righe che iniziano con % o ; sono trattate come commenti e ignorate.', 'ui.echoareas_import.error_choose_na' => 'Scegli un file .NA da importare.', 'ui.echoareas_import.error_open_na' => 'Impossibile aprire il file .NA caricato.', + 'ui.base.admin.hub_nodes' => 'Downlink BinkP', + 'ui.admin.hub_nodes.page_title' => 'Downlink', + 'ui.admin.hub_nodes.heading' => 'Downlink', + 'ui.admin.hub_nodes.intro' => 'Gestisci i nodi e i point FTN subordinati a cui questo BBS distribuisce l\'echomail.', + 'ui.admin.hub_nodes.add_node' => 'Aggiungi Downlink', + 'ui.admin.hub_nodes.edit_node' => 'Modifica Downlink', + 'ui.admin.hub_nodes.type' => 'Tipo', + 'ui.admin.hub_nodes.type_node' => 'Nodo', + 'ui.admin.hub_nodes.type_point' => 'Point', + 'ui.admin.hub_nodes.address' => 'Indirizzo', + 'ui.admin.hub_nodes.boss_address' => 'Indirizzo Boss', + 'ui.admin.hub_nodes.point_number' => 'Numero Point', + 'ui.admin.hub_nodes.name' => 'Nome', + 'ui.admin.hub_nodes.sysop_name' => 'Nome Sysop', + 'ui.admin.hub_nodes.session_password' => 'Password di Sessione', + 'ui.admin.hub_nodes.session_password_help' => 'Usata per autenticare questo subordinato quando si connette a noi. Lascia vuoto per mantenere la password esistente.', + 'ui.admin.hub_nodes.packet_password' => 'Password Pacchetto', + 'ui.admin.hub_nodes.areafix_password' => 'Password AreaFix', + 'ui.admin.hub_nodes.areafix_password_help' => 'Password che questo downlink deve inviare per gestire le proprie iscrizioni alle aree echo tramite netmail AreaFix. Lasciare vuoto per mantenere la password esistente; lasciare non impostato per disabilitare AreaFix per questo downlink.', + 'ui.admin.hub_nodes.filefix_password' => 'Password FileFix', + 'ui.admin.hub_nodes.filefix_password_help' => 'Password che questo downlink deve inviare per gestire le proprie iscrizioni alle aree file tramite netmail FileFix. Lasciare vuoto per mantenere la password esistente; lasciare non impostato per disabilitare FileFix per questo downlink.', + 'ui.admin.hub_nodes.inet_host' => 'Host Internet', + 'ui.admin.hub_nodes.inet_host_help' => 'Host alternativo per la consegna push. I point in genere non hanno un host raggiungibile e ricevono solo in pull.', + 'ui.admin.hub_nodes.port' => 'Porta', + 'ui.admin.hub_nodes.enabled' => 'Abilitato', + 'ui.admin.hub_nodes.hold_mail' => 'Trattieni Posta', + 'ui.admin.hub_nodes.compress_outbound' => 'Comprimi Uscita', + 'ui.admin.hub_nodes.compress_outbound_help' => 'Impacchetta l\'echomail/netmail in uscita raggruppato in un bundle ZIP arcmail invece di un pacchetto grezzo. Abilita questa opzione solo se il collegamento è un\'altra istanza di BinktermPHP o un mailer che rileva automaticamente le estensioni dei bundle.', + 'ui.admin.hub_nodes.allow_inbound_echomail' => 'Accetta echomail da questo subordinato', + 'ui.admin.hub_nodes.allow_inbound_netmail' => 'Accetta netmail da questo subordinato', + 'ui.admin.hub_nodes.max_packet_kb' => 'Dimensione Massima Pacchetto (KB)', + 'ui.admin.hub_nodes.max_packet_kb_help' => '0 = illimitato', + 'ui.admin.hub_nodes.queue_retention_days' => 'Conservazione Coda (Giorni)', + 'ui.admin.hub_nodes.notes' => 'Note', + 'ui.admin.hub_nodes.flags' => 'Flag', + 'ui.admin.hub_nodes.queue' => 'Conservazione', + 'ui.admin.hub_nodes.disabled' => 'Disabilitato', + 'ui.admin.hub_nodes.days' => 'giorni', + 'ui.admin.hub_nodes.delete_confirm' => 'Eliminare questo downlink?', + 'ui.admin.hub_nodes.load_failed' => 'Impossibile caricare i downlink', + 'ui.admin.hub_nodes.save_failed' => 'Impossibile salvare il downlink', + 'ui.admin.hub_nodes.saved' => 'Downlink salvato', + 'ui.admin.hub_nodes.delete_failed' => 'Impossibile eliminare il downlink', + 'ui.admin.hub_nodes.deleted' => 'Downlink eliminato', + 'ui.admin.hub_nodes.areas.title' => 'Iscrizioni alle Aree', + 'ui.admin.hub_nodes.areas.none' => 'Nessuna area echo trovata', + 'ui.admin.hub_nodes.areas.load_failed' => 'Impossibile caricare le iscrizioni alle aree', + 'ui.admin.hub_nodes.areas.save_failed' => 'Impossibile salvare le iscrizioni alle aree', + 'ui.admin.hub_nodes.areas.saved' => 'Iscrizioni alle aree salvate', + 'ui.admin.hub_nodes.fileareas.title' => 'Iscrizioni alle Aree File', + 'ui.admin.hub_nodes.fileareas.none' => 'Nessuna area file trovata', + 'ui.admin.hub_nodes.fileareas.load_failed' => 'Impossibile caricare le iscrizioni alle aree file', + 'ui.admin.hub_nodes.fileareas.save_failed' => 'Impossibile salvare le iscrizioni alle aree file', + 'ui.admin.hub_nodes.fileareas.saved' => 'Iscrizioni alle aree file salvate', ]; diff --git a/config/i18n/it/errors.php b/config/i18n/it/errors.php index 281289022..e9f73a7b5 100644 --- a/config/i18n/it/errors.php +++ b/config/i18n/it/errors.php @@ -301,6 +301,8 @@ 'errors.binkp.kept_packets.license_required' => 'La visualizzazione dei file pacchetto richiede la registrazione', 'errors.binkp.kept_packets.inspect_failed' => 'Impossibile ispezionare il pacchetto', 'errors.binkp.queue.inspect_failed' => 'Impossibile ispezionare il pacchetto in coda', + 'errors.binkp.hub_outbound.list_failed' => 'Impossibile caricare la coda downlink', + 'errors.binkp.hub_outbound.invalid_id' => 'ID elemento coda non valido', 'errors.binkp.uplink.address_hostname_required' => 'Indirizzo e hostname sono obbligatori', 'errors.binkp.uplink.address_required' => 'Indirizzo uplink obbligatorio', 'errors.messages.forward_email.email_required' => 'È richiesto un indirizzo email', @@ -767,4 +769,12 @@ 'errors.admin.appearance.term_menu_keys.duplicate_key' => 'Ogni tasto del menu deve essere univoco', 'errors.admin.appearance.term_menu_keys.quit_required' => 'Deve essere assegnato un tasto a Esci', 'errors.admin.appearance.term_menu_keys.save_failed' => 'Impossibile salvare le impostazioni dei tasti del menu', + 'errors.admin.hub_nodes.load_failed' => 'Impossibile caricare i downlink', + 'errors.admin.hub_nodes.save_failed' => 'Impossibile salvare il downlink', + 'errors.admin.hub_nodes.delete_failed' => 'Impossibile eliminare il downlink', + 'errors.admin.hub_nodes.areas_load_failed' => 'Impossibile caricare le iscrizioni alle aree', + 'errors.admin.hub_nodes.areas_save_failed' => 'Impossibile salvare le iscrizioni alle aree', + 'errors.admin.hub_nodes.fileareas_load_failed' => 'Impossibile caricare le iscrizioni alle aree file', + 'errors.admin.hub_nodes.fileareas_save_failed' => 'Impossibile salvare le iscrizioni alle aree file', + 'errors.admin.hub_nodes.next_point_failed' => 'Impossibile determinare il prossimo numero di point', ]; diff --git a/config/i18n/ru/common.php b/config/i18n/ru/common.php index 6b288d9c8..3b95e9a1f 100644 --- a/config/i18n/ru/common.php +++ b/config/i18n/ru/common.php @@ -326,7 +326,7 @@ 'ui.base.admin.licensing' => 'Лицензирование', 'ui.base.admin.bbs_settings' => 'Настройки BBS', 'ui.base.admin.appearance' => 'Внешний вид', - 'ui.base.admin.binkp_configuration' => 'Конфигурация Binkp', + 'ui.base.admin.binkp_configuration' => 'Аплинки BinkP', 'ui.base.admin.template_editor' => 'Редактор шаблонов', 'ui.base.admin.i18n_overrides' => 'Переводы', 'ui.base.admin.docs' => 'Документация', @@ -3352,8 +3352,25 @@ 'ui.binkp.uplinks' => 'Аплинки', 'ui.binkp.status_tab' => 'Статус', 'ui.binkp.uplinks_tab' => 'Аплинки', - 'ui.binkp.queues_tab' => 'Очереди', - 'ui.binkp.kept_packets_tab' => 'Сохранённые пакеты', + 'ui.binkp.queues_tab' => 'Очереди аплинков', + 'ui.binkp.hub_outbound_tab' => 'Очередь даунлинков', + 'ui.binkp.hub_outbound_heading' => 'Исходящая очередь даунлинков', + 'ui.binkp.hub_outbound.downlink' => 'Даунлинк', + 'ui.binkp.hub_outbound.type' => 'Тип', + 'ui.binkp.hub_outbound.size' => 'Размер', + 'ui.binkp.hub_outbound.attempts' => 'Попытки', + 'ui.binkp.hub_outbound.created' => 'Создано', + 'ui.binkp.hub_outbound.sent_next' => 'Отправлено / След. попытка', + 'ui.binkp.hub_outbound.error' => 'Ошибка', + 'ui.binkp.hub_outbound.empty' => 'Нет пакетов в очереди даунлинков', + 'ui.binkp.hub_outbound.inspect' => 'Просмотр', + 'ui.binkp.hub_outbound.load_failed' => 'Не удалось загрузить очередь даунлинков', + 'ui.binkp.hub_outbound.tic_fields' => 'Поля TIC', + 'ui.binkp.hub_outbound.tic_area' => 'Область', + 'ui.binkp.hub_outbound.tic_file' => 'Файл', + 'ui.binkp.hub_outbound.tic_desc' => 'Описание', + 'ui.binkp.hub_outbound.tic_ldesc' => 'Подробное описание', + 'ui.binkp.kept_packets_tab' => 'Сохранённые пакеты аплинков', 'ui.binkp.kept_packets_locked' => 'Для использования этой функции требуется зарегистрированная лицензия.', 'ui.binkp.kept_packets_register' => 'Зарегистрируйтесь, чтобы разблокировать', 'ui.binkp.logs_tab' => 'Журналы', @@ -5364,4 +5381,58 @@ 'ui.settings.pgp.upload_success' => 'PGP-ключ сохранён.', 'ui.settings.pgp.uploading' => 'Загрузка открытого ключа...', 'ui.settings.tab.pgp' => 'PGP', + 'ui.base.admin.hub_nodes' => 'Даунлинки BinkP', + 'ui.admin.hub_nodes.page_title' => 'Даунлинки', + 'ui.admin.hub_nodes.heading' => 'Даунлинки', + 'ui.admin.hub_nodes.intro' => 'Управление подчинёнными FTN-узлами и поинтами, которым эта BBS распространяет эхопочту.', + 'ui.admin.hub_nodes.add_node' => 'Добавить даунлинк', + 'ui.admin.hub_nodes.edit_node' => 'Редактировать даунлинк', + 'ui.admin.hub_nodes.type' => 'Тип', + 'ui.admin.hub_nodes.type_node' => 'Узел', + 'ui.admin.hub_nodes.type_point' => 'Поинт', + 'ui.admin.hub_nodes.address' => 'Адрес', + 'ui.admin.hub_nodes.boss_address' => 'Адрес босса', + 'ui.admin.hub_nodes.point_number' => 'Номер поинта', + 'ui.admin.hub_nodes.name' => 'Имя', + 'ui.admin.hub_nodes.sysop_name' => 'Имя сисопа', + 'ui.admin.hub_nodes.session_password' => 'Пароль сессии', + 'ui.admin.hub_nodes.session_password_help' => 'Используется для аутентификации этого подчинённого узла при подключении к нам. Оставьте пустым, чтобы сохранить текущий пароль.', + 'ui.admin.hub_nodes.packet_password' => 'Пароль пакета', + 'ui.admin.hub_nodes.areafix_password' => 'Пароль AreaFix', + 'ui.admin.hub_nodes.areafix_password_help' => 'Пароль, который этот даунлинк должен отправить для управления подписками на эхоконференции через нетмейл AreaFix. Оставьте пустым, чтобы сохранить текущий пароль; оставьте неустановленным, чтобы отключить AreaFix для этого даунлинка.', + 'ui.admin.hub_nodes.filefix_password' => 'Пароль FileFix', + 'ui.admin.hub_nodes.filefix_password_help' => 'Пароль, который этот даунлинк должен отправить для управления подписками на файловые области через нетмейл FileFix. Оставьте пустым, чтобы сохранить текущий пароль; оставьте неустановленным, чтобы отключить FileFix для этого даунлинка.', + 'ui.admin.hub_nodes.inet_host' => 'Интернет-хост', + 'ui.admin.hub_nodes.inet_host_help' => 'Альтернативный хост для push-доставки. У поинтов обычно нет маршрутизируемого хоста, и они получают почту только по pull.', + 'ui.admin.hub_nodes.port' => 'Порт', + 'ui.admin.hub_nodes.enabled' => 'Включено', + 'ui.admin.hub_nodes.hold_mail' => 'Задержать почту', + 'ui.admin.hub_nodes.compress_outbound' => 'Сжимать исходящие', + 'ui.admin.hub_nodes.compress_outbound_help' => 'Упаковывать собранную исходящую эхопочту/нетмейл в ZIP-архив arcmail вместо необработанного пакета. Включайте только если узел — другой экземпляр BinktermPHP или почтовик, автоматически распознающий расширения бандлов.', + 'ui.admin.hub_nodes.allow_inbound_echomail' => 'Принимать эхопочту от этого подчинённого узла', + 'ui.admin.hub_nodes.allow_inbound_netmail' => 'Принимать нетмейл от этого подчинённого узла', + 'ui.admin.hub_nodes.max_packet_kb' => 'Максимальный размер пакета (КБ)', + 'ui.admin.hub_nodes.max_packet_kb_help' => '0 = без ограничений', + 'ui.admin.hub_nodes.queue_retention_days' => 'Хранение очереди (дней)', + 'ui.admin.hub_nodes.notes' => 'Заметки', + 'ui.admin.hub_nodes.flags' => 'Флаги', + 'ui.admin.hub_nodes.queue' => 'Хранение', + 'ui.admin.hub_nodes.disabled' => 'Отключено', + 'ui.admin.hub_nodes.days' => 'дней', + 'ui.admin.hub_nodes.delete_confirm' => 'Удалить этот даунлинк?', + 'ui.admin.hub_nodes.load_failed' => 'Не удалось загрузить даунлинки', + 'ui.admin.hub_nodes.save_failed' => 'Не удалось сохранить даунлинк', + 'ui.admin.hub_nodes.saved' => 'Даунлинк сохранён', + 'ui.admin.hub_nodes.delete_failed' => 'Не удалось удалить даунлинк', + 'ui.admin.hub_nodes.deleted' => 'Даунлинк удалён', + 'ui.admin.hub_nodes.areas.title' => 'Подписки на эхоконференции', + 'ui.admin.hub_nodes.areas.none' => 'Эхоконференции не найдены', + 'ui.admin.hub_nodes.areas.load_failed' => 'Не удалось загрузить подписки', + 'ui.admin.hub_nodes.areas.save_failed' => 'Не удалось сохранить подписки', + 'ui.admin.hub_nodes.areas.saved' => 'Подписки сохранены', + 'ui.admin.hub_nodes.fileareas.title' => 'Подписки на файловые области', + 'ui.admin.hub_nodes.fileareas.none' => 'Файловые области не найдены', + 'ui.admin.hub_nodes.fileareas.load_failed' => 'Не удалось загрузить подписки на файловые области', + 'ui.admin.hub_nodes.fileareas.save_failed' => 'Не удалось сохранить подписки на файловые области', + 'ui.admin.hub_nodes.fileareas.saved' => 'Подписки на файловые области сохранены', ]; diff --git a/config/i18n/ru/errors.php b/config/i18n/ru/errors.php index b8ae2bee3..2e5663b5d 100644 --- a/config/i18n/ru/errors.php +++ b/config/i18n/ru/errors.php @@ -297,6 +297,8 @@ 'errors.binkp.kept_packets.license_required' => 'Для просмотра файлов пакетов требуется регистрация', 'errors.binkp.kept_packets.inspect_failed' => 'Не удалось просмотреть пакет', 'errors.binkp.queue.inspect_failed' => 'Не удалось просмотреть пакет в очереди', + 'errors.binkp.hub_outbound.list_failed' => 'Не удалось загрузить очередь даунлинков', + 'errors.binkp.hub_outbound.invalid_id' => 'Неверный идентификатор элемента очереди', 'errors.binkp.uplink.address_hostname_required' => 'Адрес и имя хоста обязательны', 'errors.binkp.uplink.address_required' => 'Адрес аплинка обязателен', 'errors.messages.forward_email.email_required' => 'Адрес электронной почты обязателен', @@ -770,4 +772,12 @@ 'errors.pgp.public_key_required' => 'Требуется открытый ключ.', 'errors.pgp.recipient_required' => 'Не удалось определить открытый ключ получателя.', 'errors.pgp.save_failed' => 'Не удалось сохранить PGP-ключ.', + 'errors.admin.hub_nodes.load_failed' => 'Не удалось загрузить даунлинки', + 'errors.admin.hub_nodes.save_failed' => 'Не удалось сохранить даунлинк', + 'errors.admin.hub_nodes.delete_failed' => 'Не удалось удалить даунлинк', + 'errors.admin.hub_nodes.areas_load_failed' => 'Не удалось загрузить подписки', + 'errors.admin.hub_nodes.areas_save_failed' => 'Не удалось сохранить подписки', + 'errors.admin.hub_nodes.fileareas_load_failed' => 'Не удалось загрузить подписки на файловые области', + 'errors.admin.hub_nodes.fileareas_save_failed' => 'Не удалось сохранить подписки на файловые области', + 'errors.admin.hub_nodes.next_point_failed' => 'Не удалось определить следующий номер поинта', ]; diff --git a/database/migrations/v20260808003922_create_hub_nodes_table.sql b/database/migrations/v20260808003922_create_hub_nodes_table.sql new file mode 100644 index 000000000..56aea9b23 --- /dev/null +++ b/database/migrations/v20260808003922_create_hub_nodes_table.sql @@ -0,0 +1,41 @@ +-- Migration: 20260808003922 - create hub_nodes table +-- Created: 2026-08-08 00:39:22 UTC + +-- Subordinate FTN systems BinktermPHP distributes echomail/netmail to. +-- A subordinate is either a 'node' (independently-addressed peer/downlink) +-- or a 'point' (addressed as one of our own AKAs plus a point number, +-- e.g. 1:153/149.1). See docs/proposals/HubPointSystemJuly2026.md. +CREATE TABLE hub_nodes ( + id SERIAL PRIMARY KEY, + node_type VARCHAR(10) NOT NULL DEFAULT 'node', + node_address VARCHAR(50) NOT NULL UNIQUE, + boss_address VARCHAR(50), + point_number INTEGER, + name VARCHAR(100), + sysop_name VARCHAR(100), + session_password VARCHAR(255), + packet_password VARCHAR(255), + inet_host VARCHAR(255), + port INTEGER, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + allow_inbound BOOLEAN NOT NULL DEFAULT TRUE, + allow_outbound BOOLEAN NOT NULL DEFAULT TRUE, + allow_inbound_echomail BOOLEAN NOT NULL DEFAULT TRUE, + allow_inbound_netmail BOOLEAN NOT NULL DEFAULT TRUE, + max_packet_kb INTEGER NOT NULL DEFAULT 0, + hold_mail BOOLEAN NOT NULL DEFAULT FALSE, + queue_retention_days INTEGER NOT NULL DEFAULT 30, + capability_flags VARCHAR(50), + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_session_at TIMESTAMPTZ, + CONSTRAINT chk_hub_nodes_node_type CHECK (node_type IN ('node', 'point')), + CONSTRAINT chk_hub_nodes_point_fields CHECK ( + (node_type = 'point' AND boss_address IS NOT NULL AND point_number IS NOT NULL) + OR + (node_type = 'node' AND boss_address IS NULL AND point_number IS NULL) + ) +); + +CREATE INDEX idx_hub_nodes_enabled ON hub_nodes (enabled) WHERE enabled = TRUE; +CREATE INDEX idx_hub_nodes_boss ON hub_nodes (boss_address) WHERE node_type = 'point'; diff --git a/database/migrations/v20260808003926_create_hub_node_areas_table.sql b/database/migrations/v20260808003926_create_hub_node_areas_table.sql new file mode 100644 index 000000000..e2a341619 --- /dev/null +++ b/database/migrations/v20260808003926_create_hub_node_areas_table.sql @@ -0,0 +1,15 @@ +-- Migration: 20260808003926 - create hub_node_areas table +-- Created: 2026-08-08 00:39:26 UTC + +-- Per-subordinate echoarea subscriptions for the hub fanout engine. +-- See docs/proposals/HubPointSystemJuly2026.md. +CREATE TABLE hub_node_areas ( + id SERIAL PRIMARY KEY, + hub_node_id INTEGER NOT NULL REFERENCES hub_nodes(id) ON DELETE CASCADE, + echoarea_id INTEGER NOT NULL REFERENCES echoareas(id) ON DELETE CASCADE, + paused BOOLEAN NOT NULL DEFAULT FALSE, + subscribed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (hub_node_id, echoarea_id) +); + +CREATE INDEX idx_hub_node_areas_echoarea ON hub_node_areas (echoarea_id); diff --git a/database/migrations/v20260808003927_create_hub_node_outbound_table.sql b/database/migrations/v20260808003927_create_hub_node_outbound_table.sql new file mode 100644 index 000000000..f20570e90 --- /dev/null +++ b/database/migrations/v20260808003927_create_hub_node_outbound_table.sql @@ -0,0 +1,31 @@ +-- Migration: 20260808003927 - create hub_node_outbound table +-- Created: 2026-08-08 00:39:27 UTC + +-- Per-subordinate outbound packet queue for the hub fanout engine. Distinct +-- from the existing uplink outbound mechanism (a flat-file .pkt drop +-- directory, data/outbound/, see src/Binkp/Queue/OutboundQueue.php) since +-- fanout to many subordinates needs per-destination retry/status tracking. +-- See docs/proposals/HubPointSystemJuly2026.md. +CREATE TABLE hub_node_outbound ( + id SERIAL PRIMARY KEY, + hub_node_id INTEGER NOT NULL REFERENCES hub_nodes(id) ON DELETE CASCADE, + message_type VARCHAR(20) NOT NULL DEFAULT 'echomail', + echoarea_id INTEGER REFERENCES echoareas(id) ON DELETE SET NULL, + echomail_id INTEGER REFERENCES echomail(id) ON DELETE SET NULL, + netmail_id INTEGER REFERENCES netmail(id) ON DELETE SET NULL, + packet_data BYTEA NOT NULL, + size_bytes INTEGER NOT NULL DEFAULT 0, + priority SMALLINT NOT NULL DEFAULT 5, + attempts SMALLINT NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + sent_at TIMESTAMPTZ, + error_message TEXT, + CONSTRAINT chk_hno_message_type CHECK (message_type IN ('echomail', 'netmail')), + CONSTRAINT chk_hno_status CHECK (status IN ('pending', 'sent', 'failed', 'held')) +); + +CREATE INDEX idx_hno_pending ON hub_node_outbound (hub_node_id, next_attempt_at) + WHERE status = 'pending'; +CREATE INDEX idx_hno_node ON hub_node_outbound (hub_node_id); diff --git a/database/migrations/v20260808164447_create_hub_node_fileareas_table.sql b/database/migrations/v20260808164447_create_hub_node_fileareas_table.sql new file mode 100644 index 000000000..711eb8509 --- /dev/null +++ b/database/migrations/v20260808164447_create_hub_node_fileareas_table.sql @@ -0,0 +1,17 @@ +-- Migration: 20260808164447 - create hub_node_fileareas table +-- Created: 2026-08-08 16:44:47 UTC + +-- Per-subordinate file area subscriptions for the hub TIC/file distribution +-- engine (Phase 4). Mirrors hub_node_areas exactly, one row per +-- (downlink, file area) pair. See docs/proposals/HubPointSystemJuly2026.md. +CREATE TABLE hub_node_fileareas ( + id SERIAL PRIMARY KEY, + hub_node_id INTEGER NOT NULL REFERENCES hub_nodes(id) ON DELETE CASCADE, + file_area_id INTEGER NOT NULL REFERENCES file_areas(id) ON DELETE CASCADE, + paused BOOLEAN NOT NULL DEFAULT FALSE, + subscribed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (hub_node_id, file_area_id) +); + +CREATE INDEX idx_hub_node_fileareas_node ON hub_node_fileareas (hub_node_id); +CREATE INDEX idx_hub_node_fileareas_filearea ON hub_node_fileareas (file_area_id); diff --git a/database/migrations/v20260808164452_add_tic_columns_to_hub_node_outbound.sql b/database/migrations/v20260808164452_add_tic_columns_to_hub_node_outbound.sql new file mode 100644 index 000000000..e12c6cb35 --- /dev/null +++ b/database/migrations/v20260808164452_add_tic_columns_to_hub_node_outbound.sql @@ -0,0 +1,16 @@ +-- Migration: 20260808164452 - add tic columns to hub_node_outbound +-- Created: 2026-08-08 16:44:52 UTC + +-- Phase 4 (TIC/File Area Distribution) of docs/proposals/HubPointSystemJuly2026.md. +-- A TIC delivery is a control-file/data-file pair, not a single .pkt, so it +-- doesn't fit packet_data unchanged. Reuses hub_node_outbound rather than a +-- parallel queue table so the Downlink Queue viewer stays a single source +-- for every distribution type (echomail/netmail/tic). +ALTER TABLE hub_node_outbound + ADD COLUMN file_id INTEGER REFERENCES files(id) ON DELETE SET NULL, + ADD COLUMN tic_file_data BYTEA, + ADD COLUMN tic_filename VARCHAR(255); + +ALTER TABLE hub_node_outbound + DROP CONSTRAINT chk_hno_message_type, + ADD CONSTRAINT chk_hno_message_type CHECK (message_type IN ('echomail', 'netmail', 'tic')); diff --git a/database/migrations/v20260808213648_add_areafix_filefix_passwords_to_hub_nodes.sql b/database/migrations/v20260808213648_add_areafix_filefix_passwords_to_hub_nodes.sql new file mode 100644 index 000000000..31ae37517 --- /dev/null +++ b/database/migrations/v20260808213648_add_areafix_filefix_passwords_to_hub_nodes.sql @@ -0,0 +1,10 @@ +-- Migration: 20260808213648 - add areafix filefix passwords to hub_nodes +-- Created: 2026-08-08 21:36:48 UTC + +-- Phase 5 (Areafix/FileFix) of docs/proposals/HubPointSystemJuly2026.md. +-- Dedicated robot passwords, separate from the binkp session_password, so +-- a subordinate's Areafix/Filefix netmail password can differ from (and be +-- rotated independently of) its binkp session credentials. +ALTER TABLE hub_nodes + ADD COLUMN areafix_password VARCHAR(255), + ADD COLUMN filefix_password VARCHAR(255); diff --git a/database/migrations/v20260809011730_add_message_payload_to_hub_node_outbound.sql b/database/migrations/v20260809011730_add_message_payload_to_hub_node_outbound.sql new file mode 100644 index 000000000..5f859ec6e --- /dev/null +++ b/database/migrations/v20260809011730_add_message_payload_to_hub_node_outbound.sql @@ -0,0 +1,13 @@ +-- Migration: 20260809011730 - add message_payload to hub_node_outbound +-- Created: 2026-08-09 01:17:30 UTC + +-- Stores the createOutboundPacket()-ready message array (from_address, +-- to_address, subject, message_text, kludge_lines, bottom_kludges, etc.) as +-- JSON for echomail/netmail rows, alongside the existing pre-rendered +-- packet_data blob. Delivery (BinkpSession::sendHubNodeOutbound()) decodes +-- this to bundle every pending row for a subordinate node into a single +-- multi-message .pkt at send time instead of sending one packet per row. +-- packet_data is kept as-is for the admin "Downlink Queue" inspect/download +-- UI and as the send-time fallback for rows queued before this column +-- existed. NULL for message_type='tic' rows, which are never bundled. +ALTER TABLE hub_node_outbound ADD COLUMN message_payload JSONB NULL; diff --git a/database/migrations/v20260809023241_add_compress_outbound_to_hub_nodes.sql b/database/migrations/v20260809023241_add_compress_outbound_to_hub_nodes.sql new file mode 100644 index 000000000..933b4644f --- /dev/null +++ b/database/migrations/v20260809023241_add_compress_outbound_to_hub_nodes.sql @@ -0,0 +1,8 @@ +-- Migration: 20260809023241 - add compress_outbound to hub_nodes +-- Created: 2026-08-09 02:32:41 UTC + +-- Opt-in per-downlink setting: when true, hub node bundled outbound +-- (BinkpSession::sendHubNodeOutboundBundle) is packed into a ZIP arcmail +-- bundle (FTS-5001 day-of-week extension) instead of sent as a raw .pkt. +ALTER TABLE hub_nodes ADD COLUMN compress_outbound BOOLEAN NOT NULL DEFAULT FALSE; + diff --git a/docs/CLI.md b/docs/CLI.md index 415dfd414..54541b590 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -405,6 +405,30 @@ php scripts/binkp_scheduler.php --interval=120 php scripts/debug_binkp.php 1:153/149 ``` +### Test Client (Point/Downlink Simulator) +`binkp_test_client.php` connects to a binkp server (typically your own, on `localhost`) as an arbitrary FTN address, for testing hub/point (downlink) support end-to-end without needing a real point system. It authenticates (plaintext or CRAM-MD5, whichever the server offers), receives and dumps anything the server pushes, and can compose and send a test netmail or echomail message. + +```bash +# Connect as a registered point, receive/dump whatever's queued for it +php scripts/binkp_test_client.php --host=localhost --address=1:153/149.1 --password=secret + +# Force plaintext auth even if the server offers CRAM-MD5 (test the plaintext-fallback path) +php scripts/binkp_test_client.php --host=localhost --address=1:153/149.1 --password=secret --no-cram + +# Compose and send a test netmail as the point +php scripts/binkp_test_client.php --host=localhost --address=1:153/149.1 --password=secret \ + --compose-netmail --to=1:1/1 --subject="Hi" --body="Test from a point" + +# Compose and send a test echomail post to an area as the point +php scripts/binkp_test_client.php --host=localhost --address=1:153/149.1 --password=secret \ + --compose-echomail --to=1:1/1 --area=GENERAL --subject="Hi" --body="Test post from a point" + +# Send an existing .pkt file as-is +php scripts/binkp_test_client.php --host=localhost --address=1:153/149.1 --password=secret --send-file=test.pkt +``` + +Received files are saved under `data/binkp_test_client/` by default (`--save-dir=PATH` to override); `.pkt` files are dumped (packet header + per-message From/To/Subject/Date/Flags, the same format as the `/binkp` admin Downlink Queue viewer) unless `--no-dump` is given. Run with `--help` for the full flag list. + ## Packet Processing ```bash # Process inbound packets diff --git a/docs/Downlinks.md b/docs/Downlinks.md new file mode 100644 index 000000000..aea876d26 --- /dev/null +++ b/docs/Downlinks.md @@ -0,0 +1,123 @@ +# Downlinks + +BinktermPHP can act as an FTN **hub** for subordinate systems below it — regular independently-addressed nodes/peers, and FidoNet-style **points** hanging off one of this system's own addresses. This is the reverse of the existing uplink relationship (where BinktermPHP receives mail from, and sends mail up to, a hub above it): here, BinktermPHP *is* the hub, and downlinks are the systems below it. + +Downlinks are managed from **Admin → Downlinks**. + +## Nodes vs. points + +A downlink is one of two types: + +- **Node** — an independently-addressed system (e.g. `2:345/67`), entered as a free-text FTN address. This covers both traditional hub→downlink relationships and symmetric peer links. +- **Point** — a system addressed as one of *this BBS's own* AKAs plus a point number (e.g. `1:153/149.1`, where `1:153/149` is an address this BBS already holds). When adding a point, the **Boss Address** is picked from the list of AKAs configured in **Admin → Networks** rather than typed freely, and a next-available **Point Number** is suggested automatically. + +Both types share the same underlying distribution, queueing, and authentication mechanics — the only structural difference is addressing. + +## Adding a downlink + +From **Admin → Downlinks**, choose **Add Downlink** and select **Node** or **Point**: + +| Field | Applies to | Notes | +|---|---|---| +| Address | Node | Free-text FTN address | +| Boss Address | Point | Picked from configured AKAs | +| Point Number | Point | Suggested automatically, editable | +| Name / Sysop Name | Both | Informational | +| Session Password | Both | Authenticates this downlink when *it* connects to us (binkp session password) | +| Packet Password | Both | `.pkt`-level password | +| AreaFix Password / FileFix Password | Both | Password this downlink must send to self-manage its own echo/file area subscriptions via netmail (see [AreaFix / FileFix](#areafix--filefix)). Independent of the session password; leaving one blank disables that robot for this downlink | +| Internet Host / Port | Node | Override host/port for push delivery; falls back to the nodelist if unset. Points typically have no routable host and are pull-only | +| Enabled | Both | Disabling stops all delivery to/from this downlink without deleting it | +| Hold Mail | Both | Pauses delivery; queued mail accumulates until released | +| Accept Echomail From This Subordinate | Both | Whether echomail posted back by this downlink is accepted | +| Accept Netmail From This Subordinate | Both | Whether netmail from this downlink is accepted for relay/routing | +| Max Packet Size (KB) | Both | `0` = unlimited | +| Queue Retention (Days) | Both | How long sent/failed queue entries are kept before being purged (see [Queue cleanup](#queue-cleanup)) | +| Notes | Both | Free text | + +## Authentication + +Downlinks authenticate with their **Session Password** when connecting to this BBS's binkp server. Both **CRAM-MD5** and **plaintext** authentication are accepted for registered downlinks, regardless of the global plaintext-fallback setting — this keeps simpler point software (which may not support CRAM-MD5) working without weakening authentication requirements for uplink connections. + +## Area subscriptions + +Each downlink has its own echomail area subscription list, managed from its row in **Admin → Downlinks**. Subscriptions can be bulk-toggled, and individual areas can be paused without removing the subscription. + +Each downlink also has a separate **file area** subscription list, managed the same way from its own row via a second checklist. See [File area (TIC) distribution](#file-area-tic-distribution). + +## Echomail distribution + +When a new echomail message is stored — whether it arrived from the network, was posted locally, or was posted back by a registered point/node — it's distributed in every direction: + +- **Down**, to every enabled, non-held downlink subscribed to that area. +- **Up**, to the echoarea's configured uplink, unless the message was received directly from that uplink or the uplink already appears in the message's SEEN-BY (both checks prevent sending mail straight back where it came from). + +Node-type downlinks get standard SEEN-BY/PATH loop-prevention bookkeeping applied (their own address is added to SEEN-BY, and this BBS's address is appended to PATH). Point-type downlinks never appear in SEEN-BY or PATH — per FTS convention, point numbers have no place in either kludge — so a point's delivery is governed purely by its subscription state, not by SEEN-BY matching. + +Mail posted by a point is tossed the same way as a locally-composed post: it carries this BBS's own SEEN-BY/PATH entry, not the point's, and fans out normally to every other subscribed downlink. + +## File area (TIC) distribution + +Files are announced to downlinks the same way they're announced to uplinks — via **TIC** (File Information Control, FSC-0087) control files paired with the data file itself — using the same subscription/queue/delivery model as echomail, extended to file areas: + +- **Down**, to every enabled, non-held downlink subscribed to the file's area, whenever a file is uploaded or received via an inbound TIC. A downlink already recorded in the file's TIC `Seenby` trail is skipped (the same loop-prevention principle as echomail SEEN-BY). +- **Up**, to the file area's configured uplink(s), unless the file was received directly from that uplink or the uplink already appears in the `Seenby` trail. + +Local-only and private file areas are never distributed to downlinks, matching the existing uplink behavior. Both node- and point-type downlinks can be subscribed to a file area, though in practice this is mainly useful for node-type downlinks — most point software has no file-area engine of its own. + +## AreaFix / FileFix + +A downlink can self-manage its own echo and file area subscriptions by sending netmail to two robot addresses at one of this BBS's AKAs: **AreaFix** (echo areas) and **FileFix** (file areas) — the server-side counterpart to the AreaFix/FileFix support this BBS already uses when *sending* commands to its own uplinks. + +**Authentication**: the netmail's Subject line must match the downlink's configured **AreaFix Password** or **FileFix Password**. A downlink with no password configured for a robot cannot use it — the message is dropped without a reply so unconfigured robots can't be probed. A registered downlink with the wrong password gets a "Password incorrect" reply; netmail from an unregistered address is silently dropped. + +**Commands** (one per line in the message body): + +| Command | Effect | +|---|---| +| `+TAG` | Subscribe to area `TAG` | +| `-TAG` | Unsubscribe from area `TAG` | +| `%LIST` | Reply with all areas available to subscribe to | +| `%QUERY` | Reply with this downlink's current subscriptions | +| `%PAUSE` | Pause all areas (sets **Hold Mail**) | +| `%RESUME` | Resume all areas (clears **Hold Mail**) | +| `%RESCAN [AREATAG] [days]` | Re-queue echomail history (AreaFix only, see below) | +| `%HELP` | Reply with the command reference | + +Only active, non-local areas are self-subscribable — sysop-only echoareas and private/local file areas never appear in `%LIST` and can't be subscribed to via `+TAG`, matching what the admin area-subscription checklists already show. A reply netmail is queued back to the downlink for every command batch, listing the result of each command. + +**`%RESCAN`** re-sends past echomail the downlink is entitled to, e.g. after it lost message history locally. With no arguments it re-queues every area the downlink is currently subscribed to, going back 182 days (~6 months) by default (maximum 3650). Add a number to change the day count (`%RESCAN 30`), an area tag to scope it to just that one area (`%RESCAN GENERAL`, must already be subscribed), or both in either order (`%RESCAN GENERAL 30` or `%RESCAN 30 GENERAL`). AreaFix only — FileFix has no per-message history to replay. + +## Netmail routing + +Netmail addressed to a registered downlink's address is delivered into its queue instead of being handled as ordinary local netmail. This covers three directions: + +- **Inbound, addressed to a downlink** — netmail arriving from the network addressed to one of this BBS's registered downlinks is forwarded on, gated by that downlink's **Accept Netmail From This Subordinate** setting. +- **Outbound, composed locally** — netmail a user on this BBS composes to a registered downlink's address is delivered directly to that downlink's queue instead of being routed toward an unrelated uplink. +- **Relayed, from a point** — netmail sent *by* a registered point to an address that is neither this BBS nor another registered downlink is relayed onward through the normal outbound routing, rather than being misdelivered locally or silently dropped. + +There is no open relay to arbitrary third-party addresses — only traffic to or from a registered downlink is handled this way. + +## Delivery + +Downlinks are served through the existing binkp server/client, using the same session mechanics as uplinks: + +- **Pull** — the downlink connects to this BBS's binkp server and authenticates; any pending queued packets are delivered during that session. +- **Push** — this BBS connects out to the downlink (for node-type downlinks with a routable **Internet Host**, or a nodelist-resolvable address) and delivers pending packets. Push delivery runs automatically on a schedule alongside uplink polling; it can also be triggered manually with `scripts/binkp_poll.php --all-hub-nodes` (see `docs/CLI.md`). + +A queued file area entry is sent as a TIC pair — the data file first, then its `.tic` control file — over the same session, rather than as a single packet. + +Points typically have no independently routable host and are effectively pull-only. + +## Monitoring the queue + +**Binkp Status → Downlink Queue** (`/binkp`) lists every queued packet across all downlinks — destination, type (echomail/netmail/tic), status, size, attempt count, and timestamps — with an **Inspect** action to view a queued packet's header and message contents (or, for a `tic` entry, its TIC control fields and referenced filename) before it's delivered. + +## Queue cleanup + +Delivered (`sent`) and permanently failed (`failed`) queue entries are not deleted immediately — they're kept for each downlink's configured **Queue Retention (Days)** (default 30) before being purged. This purge runs as part of `scripts/database_maintenance.php`. Entries still `pending` or `held` are never purged regardless of age; they remain queued until delivered, held mail is released, or the downlink is deleted. + +## Limitations + +- **File-attach netmail routed through a downlink** currently forwards only the `.pkt` header, not the referenced attached file. +- There is no self-service "request a point" flow; points are registered by the sysop. diff --git a/docs/MAINTENANCE.md b/docs/MAINTENANCE.md index 64643b211..4276fe2c8 100644 --- a/docs/MAINTENANCE.md +++ b/docs/MAINTENANCE.md @@ -17,7 +17,8 @@ The maintenance script performs the following cleanup operations: 5. **Webshare Links** - Removes expired share links 6. **Rejected Pending Users** - Removes rejected applications older than 90 days 7. **Login Attempts** - Removes login attempts older than 30 days (if table exists) -8. **Database Vacuum** - Runs PostgreSQL VACUUM and ANALYZE on key tables +8. **Downlink Outbound Queue** - Removes sent/failed packets queued for [downlinks](Downlinks.md) once they're older than each downlink's own configured retention period (pending/held packets are never removed by age) +9. **Database Vacuum** - Runs PostgreSQL VACUUM and ANALYZE on key tables ### Usage @@ -111,6 +112,7 @@ The script uses the following retention periods: | Webshare links | Per link expiry | User-configurable expiration | | Rejected pending users | 30 days | Audit trail for rejections | | Login attempts | 30 days | Security monitoring | +| Downlink outbound queue (sent/failed) | Per-downlink, default 30 days | Configurable per downlink in **Admin → Downlinks**; see [Downlinks](Downlinks.md#queue-cleanup) | \* Only deleted when both sender AND recipient have marked as deleted \** Deleted when expired or used diff --git a/docs/UPGRADING_1.10.0.md b/docs/UPGRADING_1.10.0.md index 8d58099c1..498d38a90 100644 --- a/docs/UPGRADING_1.10.0.md +++ b/docs/UPGRADING_1.10.0.md @@ -5,6 +5,7 @@ Make sure you have a current backup of your database and files before upgrading. ## Table of Contents - [Summary of Changes](#summary-of-changes) + - [Downlinks: Act as a Hub for Subordinate Nodes and Points](#downlinks-act-as-a-hub-for-subordinate-nodes-and-points) - [Echomail Unread/Read Filter (Threaded View)](#echomail-unreadread-filter-threaded-view) - [Auto Feed (RSS/Bluesky) Watermark Fix](#auto-feed-rssbluesky-watermark-fix) - [Duplicate Auto-Created Echo Areas from Domain Case Mismatch](#duplicate-auto-created-echo-areas-from-domain-case-mismatch) @@ -16,6 +17,7 @@ Make sure you have a current backup of your database and files before upgrading. - [Docker Image Was Missing Required PHP Extensions](#docker-image-was-missing-required-php-extensions) - [Docker: BinkStream Realtime Server Was Not Started or Reachable](#docker-binkstream-realtime-server-was-not-started-or-reachable) - [Docker: PHP Fatal Errors Were Silently Lost](#docker-php-fatal-errors-were-silently-lost) +- [Downlinks: Act as a Hub for Subordinate Nodes and Points](#downlinks-act-as-a-hub-for-subordinate-nodes-and-points-1) - [Echomail Unread/Read Filter (Threaded View)](#echomail-unreadread-filter-threaded-view-1) - [Auto Feed (RSS/Bluesky) Watermark Fix](#auto-feed-rssbluesky-watermark-fix-1) - [Duplicate Auto-Created Echo Areas from Domain Case Mismatch](#duplicate-auto-created-echo-areas-from-domain-case-mismatch-1) @@ -30,6 +32,10 @@ Make sure you have a current backup of your database and files before upgrading. ## Summary of Changes +### Downlinks: Act as a Hub for Subordinate Nodes and Points + +- BinktermPHP can now act as an FTN hub for subordinate systems: independently-addressed nodes/peers, and FidoNet-style points hanging off one of its own AKAs. Manage them from the new **Admin → Downlinks** page: register a downlink, choose which echo areas it receives, and it's delivered to (and can deliver mail back) automatically. See `docs/Downlinks.md` for full details. + ### Echomail Unread/Read Filter (Threaded View) - The **Unread** and **Read** tabs on an echo area's message list now show a flat list of just the matching messages instead of trying to preserve conversation threading. Threading unread/read results could mix in already-read messages, hide genuinely unread ones inside a conversation, and slow page loads considerably in areas with deep reply chains. @@ -66,6 +72,25 @@ Make sure you have a current backup of your database and files before upgrading. --- +## Downlinks: Act as a Hub for Subordinate Nodes and Points + +BinktermPHP can now act as an FTN **hub**, distributing echomail and routing netmail to subordinate systems below it — the reverse of the existing uplink relationship, where BinktermPHP receives mail from and sends mail up to a hub above it. + +Subordinates are managed from a new admin page, **Admin → Downlinks**, and come in two kinds: + +- **Node** — an independently-addressed system (for example `2:345/67`), entered as a free-text FTN address. Covers both traditional downlinks and symmetric peer links. +- **Point** — a system addressed as one of this BBS's own AKAs plus a point number (for example `1:153/149.1`). The boss address is picked from the AKAs already configured in **Admin → Networks**, and a next-available point number is suggested automatically. + +Each downlink has its own echo area subscription list, its own session/packet passwords, and independent enable/hold/quota controls. New echomail is distributed to every subscribed downlink and, separately, forwarded up to the area's configured uplink (unless it just came from that uplink). Netmail addressed to a registered downlink is delivered into its queue instead of being handled as ordinary local netmail, and netmail from a registered point addressed elsewhere is relayed onward rather than dropped. There is no open relay — only traffic to or from an explicitly registered downlink is handled this way. + +Downlinks connect the same way uplinks do, over binkp: they can either poll BinktermPHP (pull) or be polled by it on a schedule (push, for node-type downlinks with a routable host — most points are pull-only). Both CRAM-MD5 and plaintext authentication are accepted for registered downlinks regardless of the global plaintext-fallback setting, since simple point software may not support CRAM-MD5. + +A new **Binkp Status → Downlink Queue** tab shows every queued packet across all downlinks with an inspector to view a packet's contents before delivery, and a new `scripts/binkp_test_client.php --compose-echomail`/`--compose-netmail` mode lets you simulate a downlink for testing without a real point client. + +Delivered and failed queue entries are kept for each downlink's configured retention period (default 30 days) and then purged automatically by `scripts/database_maintenance.php`; pending or held mail is never purged regardless of age. + +See `docs/Downlinks.md` for the full reference, including field descriptions and current limitations (no Areafix self-service subscription management yet, and file-attach netmail forwards only the `.pkt` header). + ## Echomail Unread/Read Filter (Threaded View) When browsing an echo area in threaded (conversation) view, switching to the **Unread** or **Read** tab now shows a flat list of just the messages matching that filter, rather than the full conversation tree. diff --git a/docs/index.md b/docs/index.md index 364ac31c2..813e054f8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,6 +26,7 @@ Complete reference for sysops and developers. New here? Start with [Getting Star - [FREQ](FREQ.md) — File request (FREQ) serving and requesting: modes, magic names, routing, and CLI tools - [LovlyNet](LovlyNet.md) — LovlyNet network file sharing and FileFix integration - [AreaFix / FileFix](AreaFix.md) — Managing echomail and file-area subscriptions with hub uplinks +- [Downlinks](Downlinks.md) — Acting as a hub for subordinate nodes and points: registration, area subscriptions, echomail/netmail distribution, and delivery --- diff --git a/docs/proposals/Downlink_Distribution_Proposal.md b/docs/proposals/Downlink_Distribution_Proposal.md index 645524bc0..eb9d6ee37 100644 --- a/docs/proposals/Downlink_Distribution_Proposal.md +++ b/docs/proposals/Downlink_Distribution_Proposal.md @@ -2,6 +2,10 @@ --- +**SUPERSEDED:** This proposal has been unified with `docs/proposals/PointRouting_Proposal.md` into `docs/proposals/HubPointSystemJuly2026.md`, which additionally adds FidoNet point (`boss/point` address) support to the same distribution mechanism. Refer to `docs/proposals/HubPointSystemJuly2026.md` for current design work. + +--- + **DRAFT DOCUMENT** This proposal is a draft document generated by AI and may not have been reviewed for accuracy. The technical details, implementation decisions, and schema described herein should be validated by developers familiar with FidoNet protocols and the BinktermPHP codebase before implementation begins. diff --git a/docs/proposals/HubPointSystemJuly2026.md b/docs/proposals/HubPointSystemJuly2026.md new file mode 100644 index 000000000..f541a0d99 --- /dev/null +++ b/docs/proposals/HubPointSystemJuly2026.md @@ -0,0 +1,640 @@ +# Hub & Point System Proposal + +--- + +**DRAFT DOCUMENT** + +This proposal is a draft document generated by AI and may not have been reviewed for accuracy. The technical details, implementation decisions, and schema described herein should be validated by developers familiar with FidoNet protocols and the BinktermPHP codebase before implementation begins. + +--- + +## Overview + +This document proposes a unified **hub distribution system** for BinktermPHP: the ability to distribute echomail, route netmail, and (Phase 4) distribute file-area content via TIC to subordinate systems, where a subordinate is either a **regular FTN node** (an independently-addressed peer or downlink BBS) or a **point** (a system hanging off one of BinktermPHP's own AKAs, addressed as `boss.point`, e.g. `1:153/149.1`). Both are modelled as the same kind of subordinate relationship — the difference is addressing and a handful of FTN-specific semantics, not the distribution mechanics. + +This is distinct from the existing **uplink** model, where BinktermPHP receives mail from and sends mail up to a hub. Here BinktermPHP *is* the hub, distributing to nodes and points below it. + +## Relationship to Prior Proposals + +This proposal unifies and supersedes two earlier drafts: + +- **`docs/proposals/Downlink_Distribution_Proposal.md`** (2026-03-14) — designed echomail fanout, SEEN-BY/PATH handling, and binkp push/pull distribution for independently-addressed downlinks and peers. That design is carried forward here largely intact; it did not address point addressing. +- **`docs/proposals/PointRouting_Proposal.md`** (2026-01-30) — designed FidoNet point support (`boss/point` addressing, point registration/auth, point-specific binkp session handling). Its **Approach A (point-only mode)** is carried forward here. Its **Approach B (full transit/open relay routing to arbitrary third-party addresses)** is explicitly **out of scope** for this proposal — see [Out of Scope](#out-of-scope). + +Those two documents can be treated as historical background; new implementation work should reference this document. + +--- + +## Implementation Status + +**Phase 1 (Core Infrastructure): Implemented**, on branch `hubpoint`, committed (`36c4ab8d`). Verified end-to-end against a dev database (node + point subscriber, fanout, SEEN-BY/PATH merge, no delivery). + +**Phase 2 (Delivery): Implemented**, on branch `hubpoint`, committed (`7ad64d70`). Both pull (a subordinate connects to us and authenticates against `hub_nodes`) and push (we poll a node-type subordinate with a routable host) work. Verified end-to-end with a live local binkp session: a raw CRAM-MD5 client simulating a hub node authenticated against `hub_nodes` and received its queued `hub_node_outbound` packet, which was then correctly marked `status='sent'`. See [Modified Files](#modified-files) for the exact delivery mechanics — notably, `BinkpClient::connect()`'s existing non-uplink fallback chain now also checks `hub_nodes` before falling to nodelist/DNS resolution, so both the CLI (`scripts/binkp_poll.php
`) and the Scheduler's existing `AdminDaemonClient::binkPoll()` IPC path resolve hub nodes automatically with no admin-daemon protocol changes needed. + +**Phase 3 (Netmail routing): Implemented**, on branch `hubpoint`, committed (`0e4c6a7f`, with follow-on fixes in `4ddc552b` and `b17f0398`). Three directions are covered: +- **Inbound transit** (`HubNetmailRouter::routeIfHubNode()`) — netmail arriving whose destination matches a registered, enabled `hub_nodes` entry (with `allow_inbound_netmail`, not held) is forwarded into `hub_node_outbound` instead of being dropped as undeliverable. Originally gated off by default behind a `HUB_ROUTE_NETMAIL` env flag — **removed** after a real reply to a point (`227:1/400.1`) was silently dropped as undeliverable because the flag defaulted off and nothing had set it. This path is core "deliver mail addressed to a registered downlink" functionality, not an opt-in relay feature, so it's now gated solely by the per-node `allow_inbound_netmail` flag, matching the other two directions below. `docs/CONFIGURATION.md`'s entry for the flag was removed accordingly. +- **Relay from a point** (`HubNetmailRouter::relayIfFromHubNode()`) — netmail received *from* a registered point/downlink, addressed to neither us nor another registered hub node, is relayed onward via the normal uplink-routing outbound queue instead of being dropped. Added after discovering that `BinkdProcessor::findTargetUser()`'s name-based fallback matching (e.g. "To: sysop") ignored the destination address entirely, so a point relaying mail through us to a *different* system got misdelivered into our own local sysop's inbox instead of relayed or dropped. Fixed by gating that fallback on the destination actually being one of our own AKAs. +- **Outbound to a registered downlink** (`HubNetmailRouter::routeOutboundIfHubNode()`) — a user composing netmail to a registered point (`227:1/400.1`) got a `.pkt` addressed to `227:1/1` instead, because `MessageHandler::spoolOutboundNetmail()`'s uplink network-pattern routing (`BinkpConfig::getUplinkForDestination()`) has no knowledge of `hub_nodes` and matched the zone/net wildcard of an unrelated uplink. Fixed by checking `HubNodeManager::getByAddress()` before uplink routing and delivering straight to `hub_node_outbound` when the destination is a registered, enabled downlink with `allow_outbound`. + +All three directions were verified end-to-end with real binkp sessions (`scripts/binkp_test_client.php`), including two follow-on bugs found only by testing actual relayed traffic rather than synthetic rows: relayed/transit messages were getting a duplicate `PID`/tearline stacked on top of the original (`writeMessage()` unconditionally regenerates both; fixed with a `skip_default_pid_tearline` flag) and were missing a `Via` kludge recording the hop, with any prior Via history silently dropped (fixed with `generateViaLine()` plus `EchomailSeenBy::parseViaLines()`/`formatViaLines()`, applied to every hub relay/delivery path including `HubFanout`'s echomail side). + +**Remaining scope boundary**: **file-attach netmail** (`FILE_ATTACH` bit) routed to a hub node — any direction — forwards only the `.pkt` header, not the referenced attached file; the existing attachment-delivery mechanism is keyed to a *local* netmail row that doesn't exist for transit mail. Noted as a known limitation, not solved in Phase 3. + +**Phase 4 (TIC / File Area Distribution): Implemented**, on branch `hubpoint`. Extends the same hub-distribution pattern used for echomail to file areas: + +- `hub_node_fileareas` table (mirrors `hub_node_areas`) and new `hub_node_outbound` columns (`file_id`, `tic_file_data`, `tic_filename`), with `message_type` widened to accept `'tic'`. +- `HubFanout::fanoutFile()` — subscriber lookup via `hub_node_fileareas`, Seenby-based loop prevention (comparing full FTN addresses, unlike echomail's compact net/node SEEN-BY), and TIC control-file generation addressed to each downlink via a new `TicFileGenerator::buildTicContentForDownlink()`. +- Hooked into `FileAreaManager::finalizeApprovedUserUpload()` (both `uploadFile()`/`uploadFileFromPath()` funnel through it) right after the existing uplink `createTicFilesForUplinks()` call, and into `scripts/process_packets.php`'s inbound-TIC handling after a successful, non-duplicate store. +- **Uplink relay for files received from a downlink/point**: rather than a separate relay path, `TicFileGenerator::createTicFilesForUplinks()` itself gained a per-uplink loop guard (`uplinkAlreadyHasFile()`) checking the file's `uploaded_from_address` and `tic_seenby` columns — this makes the *existing* uplink-distribution call, now also invoked from `process_packets.php` for inbound TIC files, naturally skip sending a file back to the uplink it arrived from or to an uplink that's already in its Seenby trail. Locally-uploaded files have neither field set, so this is a no-op for the pre-existing upload path. +- `BinkpSession::sendHubNodeOutbound()` gained `sendHubNodeTicRow()`: for a `message_type='tic'` row, writes the data file (under its original filename) and the `.tic` control file to a per-row temp directory and sends the data file first, then the control file — matching how `sendFiles()` already sends uplink-bound TIC pairs. +- Admin UI: a second per-downlink subscription checklist (file areas) in `templates/admin/hub_nodes.twig`, backed by `/admin/api/hub-nodes/{id}/fileareas` (GET/PUT). +- Downlink Queue viewer (`/binkp`, "Downlink Queue" tab): `tic` rows list and inspect like `echomail`/`netmail` rows; inspecting a `tic` row parses and displays its TIC control fields (Area, File, Size, To/From, Path, Seenby, description) instead of attempting to parse it as an FTS-0001 `.pkt`, and downloading fetches the referenced data file itself rather than the control text. + +See [TIC / File Area Distribution](#tic--file-area-distribution) for the design. + +**Phase 5 (Areafix / FileFix, server-side): Implemented**, on branch `hubpoint`. New `src/Hub/HubAreafixProcessor.php` intercepts inbound netmail addressed to `AreaFix`/`FileFix` at one of our own AKAs, at the very top of `BinkdProcessor::storeNetmail()` (before hub-node transit routing and the FREQ intercept — this is mail addressed to us, to be processed as a command, not delivered or routed anywhere). + +- **Authentication deviates from the original sketch**: rather than reusing `session_password`, `hub_nodes` gained two new dedicated columns, `areafix_password` and `filefix_password` (checked against the netmail Subject line, per standard Areafix convention), so a subordinate's robot password can differ from its binkp session password and either robot can be disabled independently by leaving its password unset. A registered downlink with no password configured for a robot, or an unregistered sender, gets no reply at all (avoids becoming a backscatter/probing oracle); a registered downlink with the wrong password gets a "Password incorrect" reply. +- **Two separate robots**, not one combined one: `AreaFix` manages `hub_node_areas`, `FileFix` manages `hub_node_fileareas` — mirrors the existing client-side `AreaFixManager` naming and avoids any ambiguity between echo and file area tag namespaces. +- **Self-subscribable areas are restricted** to active, non-local echoareas (excluding sysop-only) and active, non-local, non-private file areas — the same eligibility already used by the admin subscription checklists — rather than a separate sysop-curated allowlist table. +- All standard commands are implemented: `+TAG`/`-TAG` (subscribe/unsubscribe), `%LIST`, `%QUERY`, `%HELP`, `%PAUSE`/`%RESUME` (toggle `hub_nodes.hold_mail`). A reply netmail is queued directly into `hub_node_outbound` (reusing `HubNetmailRouter::buildAndEnqueue()`, widened from `private` to `public` for this) listing the result of every command in the batch. +- **`%RESCAN [AREATAG] [days]`, not in the original sketch**: re-queues historical echomail into `hub_node_outbound` for a downlink to receive again, matching common areafix convention (e.g. Mystic). With no area tag, rescans every area the downlink is currently subscribed to; with one, only that area (must already be subscribed - `%RESCAN` can't be used to probe unsubscribed history). Tag and day count may appear in either order; a purely-numeric token is always the day count. Defaults to 182 days (~6 months) if no day count is given, capped at 3650. Echomail only, not implemented for FileFix (file areas have no per-message history to replay). Implemented as `HubFanout::rescanForNode()`, which always resends regardless of SEEN-BY - a rescan is a deliberate re-request, not a fresh toss, so it must not be blocked by the "downlink already in SEEN-BY" loop guard that live fanout uses. + +See the [Implementation Plan](#implementation-plan) checklist below for the item-by-item breakdown, and the [New Files](#new-files) / [Modified Files](#modified-files) tables for what actually landed vs. what was originally sketched (a few paths diverged from the original plan during implementation). + +**Admin UI labeling note:** the admin nav item and page (`/admin/hub-nodes`) display as **"Downlinks"**, not "Hub Nodes" — "Hub Nodes" read as if it configured our *upstream* hubs, when it actually configures the *downstream* nodes/points we distribute to. Only the user-facing label changed; internal naming (`hub_nodes` table, `HubNodeManager`, `HubFanout`, `hub_node_outbound`, the `/admin/api/hub-nodes` routes, i18n key names like `ui.admin.hub_nodes.*`) is unchanged and should stay as-is in future phases unless a broader rename is explicitly requested. + +--- + +## Table of Contents + +- [Concepts](#concepts) +- [Requirements Summary](#requirements-summary) +- [Addressing Model](#addressing-model) +- [Database Schema](#database-schema) +- [Echomail Fanout Engine](#echomail-fanout-engine) +- [Netmail Routing](#netmail-routing) +- [SEEN-BY and PATH Handling](#seen-by-and-path-handling) +- [Binkp Integration](#binkp-integration) +- [Point-Specific Session Handling](#point-specific-session-handling) +- [Admin Interface](#admin-interface) +- [TIC / File Area Distribution](#tic--file-area-distribution) +- [Areafix / FileFix (Future Phase)](#areafix--filefix-future-phase) +- [Out of Scope](#out-of-scope) +- [Implementation Plan](#implementation-plan) +- [New Files](#new-files) +- [Modified Files](#modified-files) +- [Decisions](#decisions) +- [Open Questions](#open-questions) + +--- + +## Concepts + +| Term | Meaning | +|---|---| +| **Uplink** | A node we receive echomail from and send our echomail up to. Already supported (`config/binkp.json` `uplinks`). | +| **AKA** | One of our own addresses. BinktermPHP already supports multiple AKAs, one per uplink/network (`uplinks[].me` in `config/binkp.json`), plus a primary `system.address`. | +| **Downlink / Node** | A subordinate system with its own independent FTN address (e.g. `2:345/67`). Mail exchange may be asymmetric (hub → downlink) or symmetric (peer). | +| **Point** | A subordinate system addressed as one of *our own* AKAs plus a point number (e.g. `1:153/149.1`, where `1:153/149` is an AKA we already hold). Points are not independently listed in the nodelist; they exist only in relation to their boss address. | +| **Boss address** | The AKA a point hangs off. Must be one of BinktermPHP's own configured addresses. | +| **Peer** | A node-type subordinate where mail exchange is bidirectional and symmetric. Modelled identically to a downlink — the distinction is social/topological, not technical. | +| **Push** | We poll the subordinate to deliver their waiting mail. | +| **Pull** | The subordinate connects to our binkp server to collect their mail. | +| **Areafix** | A netmail robot that lets subordinates self-manage their area subscriptions via netmail commands. Planned for a later phase. | + +--- + +## Requirements Summary + +- Support both node-type downlinks/peers and point-type subordinates through one distribution mechanism +- Up to ~100 subordinates total (nodes + points combined) +- Per-subordinate area subscriptions (which echoes they receive) +- Both push (we poll) and pull (they poll us) delivery, for nodes and points alike +- Echomail fanout with correct SEEN-BY/PATH handling, aware of the FTS constraint that point numbers never appear in SEEN-BY +- Netmail passthrough/routing to known nodes and points +- Admin UI for managing subordinates and subscriptions, including point registration under a chosen AKA +- No breaking changes to existing uplink behaviour +- File area (TIC) distribution to subordinates, matching the same subscription/queue/delivery model as echomail (Phase 4) +- Areafix/Filefix robot deferred to a later phase but schema must accommodate it cleanly + +--- + +## Addressing Model + +BinktermPHP already tracks multiple addresses for itself: + +- `system.address` in `config/binkp.json` — the primary address +- `uplinks[].me` in `config/binkp.json` — the AKA used when talking to each specific uplink/network + +A point's address is always `boss.point`, where `boss` is one of these existing AKAs. When registering a point, the admin UI must offer a picker populated from the current AKA list rather than free-text entry, to prevent registering a point under an address BinktermPHP doesn't actually hold. A regular node/downlink, by contrast, is any independently-addressed system and is entered as a free-text FTN address. + +This distinction — "does this subordinate's address extend one of our own AKAs, or is it a fully independent address" — is the only structural difference between the two subordinate types; everything downstream (queueing, admin UI, binkp session handling) is shared. + +--- + +## Database Schema + +A single `hub_nodes` table (generalized from the `downlinks` table in the earlier proposal) covers both subordinate types, distinguished by `node_type`. + +### `hub_nodes` table + +```sql +CREATE TABLE hub_nodes ( + id SERIAL PRIMARY KEY, + node_type VARCHAR(10) NOT NULL DEFAULT 'node', -- 'node' | 'point' + node_address VARCHAR(50) NOT NULL UNIQUE, -- e.g. 2:345/67 (node) or 1:153/149.1 (point) + boss_address VARCHAR(50), -- points only: which of our AKAs this point hangs off + point_number INTEGER, -- points only: the .N suffix + name VARCHAR(100), -- system name + sysop_name VARCHAR(100), + session_password VARCHAR(255), -- binkp session password (they poll us) + packet_password VARCHAR(255), -- .pkt password + inet_host VARCHAR(255), -- override host for push; falls back to nodelist (nodes only; points rarely have a routable host) + port INTEGER, -- override port for push; falls back to nodelist/default + enabled BOOLEAN NOT NULL DEFAULT TRUE, + allow_inbound BOOLEAN NOT NULL DEFAULT TRUE, -- they may poll us + allow_outbound BOOLEAN NOT NULL DEFAULT TRUE, -- we poll them + allow_inbound_echomail BOOLEAN NOT NULL DEFAULT TRUE, -- accept echomail from them (peer/point posting back) + allow_inbound_netmail BOOLEAN NOT NULL DEFAULT TRUE, -- accept netmail from them for onward routing + max_packet_kb INTEGER NOT NULL DEFAULT 0, -- 0 = unlimited + hold_mail BOOLEAN NOT NULL DEFAULT FALSE, -- hold mail (paused) + compress_outbound BOOLEAN NOT NULL DEFAULT FALSE, -- ZIP the bundled outbound .pkt (see BinkpSession::zipOutboundPacket()) + queue_retention_days INTEGER NOT NULL DEFAULT 30, -- days to keep sent outbound rows + capability_flags VARCHAR(50), -- points: informational CM/ICM-style flags + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_session_at TIMESTAMPTZ, + CONSTRAINT chk_hub_nodes_point_fields CHECK ( + (node_type = 'point' AND boss_address IS NOT NULL AND point_number IS NOT NULL) + OR + (node_type = 'node' AND boss_address IS NULL AND point_number IS NULL) + ) +); + +CREATE INDEX idx_hub_nodes_address ON hub_nodes (node_address); +CREATE INDEX idx_hub_nodes_enabled ON hub_nodes (enabled) WHERE enabled = TRUE; +CREATE INDEX idx_hub_nodes_boss ON hub_nodes (boss_address) WHERE node_type = 'point'; +``` + +### `hub_node_areas` table + +Maps which echo areas a subordinate receives. Identical semantics for nodes and points. + +```sql +CREATE TABLE hub_node_areas ( + id SERIAL PRIMARY KEY, + hub_node_id INTEGER NOT NULL REFERENCES hub_nodes(id) ON DELETE CASCADE, + echoarea_id INTEGER NOT NULL REFERENCES echoareas(id) ON DELETE CASCADE, + paused BOOLEAN NOT NULL DEFAULT FALSE, -- temporary hold on this area only + subscribed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (hub_node_id, echoarea_id) +); + +CREATE INDEX idx_hub_node_areas_node ON hub_node_areas (hub_node_id); +CREATE INDEX idx_hub_node_areas_echoarea ON hub_node_areas (echoarea_id); +``` + +### `hub_node_outbound` table + +Queue of bundled packets waiting for delivery to each subordinate. Keeping per-node, per-packet rows allows reliable retry, status tracking, and per-node queue inspection. This is a database-backed queue distinct from the existing uplink outbound mechanism, which is a flat-file `.pkt` drop directory (`data/outbound/`, see `src/Binkp/Queue/OutboundQueue.php`) with no per-destination DB row today. The two do not need to be unified; `hub_node_outbound` is purpose-built for fanout to many subordinates. + +```sql +CREATE TABLE hub_node_outbound ( + id SERIAL PRIMARY KEY, + hub_node_id INTEGER NOT NULL REFERENCES hub_nodes(id) ON DELETE CASCADE, + message_type VARCHAR(20) NOT NULL DEFAULT 'echomail', -- echomail | netmail + echoarea_id INTEGER REFERENCES echoareas(id) ON DELETE SET NULL, + echomail_id INTEGER REFERENCES echomail(id) ON DELETE SET NULL, + netmail_id INTEGER REFERENCES netmail(id) ON DELETE SET NULL, + packet_data BYTEA NOT NULL, + size_bytes INTEGER NOT NULL DEFAULT 0, + priority SMALLINT NOT NULL DEFAULT 5, + attempts SMALLINT NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending | sent | failed | held + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + sent_at TIMESTAMPTZ, + error_message TEXT +); + +CREATE INDEX idx_hno_pending ON hub_node_outbound (hub_node_id, next_attempt_at) + WHERE status = 'pending'; +CREATE INDEX idx_hno_node ON hub_node_outbound (hub_node_id); +``` + +--- + +## Echomail Fanout Engine + +### When fanout fires + +Fanout runs whenever echomail is added to the system, from two sources: + +1. **Inbound packet processing** — after `PacketProcessor` stores an inbound echomail message +2. **Local posting** — after a user or robot posts to an echo area + +A `HubFanout` class handles the logic, called by the packet processor and the message posting path. + +### Fanout algorithm + +``` +For each new echomail message M in area A: + 1. Fetch all active hub_nodes subscribed to A (enabled=true, paused=false, hold_mail=false) + 2. Parse M's SEEN-BY kludge into a set of net/node entries + 3. For each subscriber D: + a. If D.node_type = 'node': + - If D's net/node is already in SEEN-BY -> skip (already has it) + - Build a copy of the message with SEEN-BY updated (add our address + D's address) + and PATH updated (append our address) + b. If D.node_type = 'point': + - Points are never skipped based on SEEN-BY — FTS SEEN-BY lines have no point-number + field, so a point can never legitimately appear there. Subscription state alone + governs delivery. + - The message is queued as-is with respect to SEEN-BY/PATH: no separate SEEN-BY + entry is added for the point, and PATH is not extended (a point is not an + independent routing hop). + c. Serialise to .pkt format addressed to D.node_address + d. INSERT into hub_node_outbound (hub_node_id=D, status='pending') +``` + +### Mail originated by a point + +When a point posts an echomail message back to BinktermPHP, it is tossed as if it were a local post: it receives our own SEEN-BY/PATH entry (not the point's), and it fans out to other subscribed node-type and point-type subordinates normally. Points never appear as a distinct hop in SEEN-BY/PATH themselves. + +### SEEN-BY address format + +FTN SEEN-BY lines use the compact `net/node` form (zone is implicit from packet header). The fanout engine must: + +- Parse all `SEEN-BY:` lines in the message +- Expand them using the zone from the packet/area context +- Emit correctly formatted SEEN-BY lines in outbound packets +- Never write a point-suffixed entry into a SEEN-BY line + +### Batching + +Rather than one `hub_node_outbound` row per message per subordinate becoming its own transfer (which could be excessive for high-volume areas), the delivery worker batches pending messages into a single `.pkt` file per session, up to `max_packet_kb`. This is handled at delivery time, not at queue time. The queue rows act as a work list; the delivery worker assembles them into packets on the fly. + +--- + +## Netmail Routing + +When a netmail arrives whose destination address matches a `hub_nodes.node_address`: + +1. `PacketProcessor` checks the destination against `hub_nodes.node_address`. Because a point's `node_address` already includes the `.N` suffix (e.g. `1:153/149.1`), this is a single exact-match lookup that works identically for nodes and points — no special-casing needed at match time. +2. If matched and `allow_inbound_netmail` is true: serialise the message to a `.pkt` addressed to the subordinate and insert into `hub_node_outbound` with `message_type='netmail'`. +3. If not matched: leave existing behaviour (store locally or discard). No open relay to unregistered addresses — see [Out of Scope](#out-of-scope). + +Netmail routing is opt-in per subordinate via `allow_inbound_netmail` (default true) and globally via a config flag `HUB_ROUTE_NETMAIL=true`. + +> **Implementation deviation:** the global `HUB_ROUTE_NETMAIL` flag was removed after initial ship — see [Implementation Status](#implementation-status). Delivering netmail addressed to a registered downlink is core functionality gated solely by the per-node `allow_inbound_netmail` flag, matching how the other two `HubNetmailRouter` methods already worked; a second flag that defaulted to *off* silently broke this path in practice (a real reply to a point never arrived, dropped as undeliverable) until a sysop discovered and set it. + +--- + +## SEEN-BY and PATH Handling + +Correct SEEN-BY and PATH kludge management is essential to prevent mail loops among node-type subordinates. Points are structurally loop-free (they are leaves), so the rules below apply to nodes; points are handled by the simpler rule in [Echomail Fanout Engine](#echomail-fanout-engine). + +### Rules (node-type subordinates) + +- **Before forwarding**: check the subordinate's address is not already in SEEN-BY +- **When building outbound packet**: + - Merge and sort all SEEN-BY entries; add our own address and the subordinate's address + - Append our address to PATH (do not duplicate if already present) +- **Loop detection**: if our own address appears in PATH more than once, do not forward + +### Key class: `EchomailSeenBy` + +```php +class EchomailSeenBy { + public static function parse(string $seenByLines): array; // returns [zone => [net => [node, ...]]] + public static function contains(array $seenBy, string $address): bool; + public static function addAddress(array $seenBy, string $address): array; + public static function format(array $seenBy): string; // back to SEEN-BY: lines + public static function isPointAddress(string $address): bool; // detects a `net/node.point` form +} +``` + +`isPointAddress()` is the hook `HubFanout` uses to branch between the node path (SEEN-BY/PATH mutation) and the point path (no mutation). + +--- + +## Binkp Integration + +### Pull: subordinate polls us + +When a node or point connects to our binkp server: + +1. Server looks up the connecting address against `hub_nodes.node_address` (exact match; point addresses already carry their `.N` suffix so no separate point lookup is required) +2. Authenticates using `hub_nodes.session_password` +3. Queries `hub_node_outbound WHERE hub_node_id=? AND status='pending'` and streams those packets +4. On successful transfer, marks rows `status='sent'`, updates `hub_nodes.last_session_at` + +The existing `BinkpSession` class (`src/Binkp/Protocol/BinkpSession.php`) needs to be extended to handle hub sessions alongside uplink sessions. The main differentiator is the outbound source: uplink sessions read from the flat-file outbound directory (`data/outbound/`); hub sessions read from `hub_node_outbound`. + +### Push: we poll the subordinate + +The existing binkp poll mechanism targets uplinks. New `--hub-node=
` / `--all-hub-nodes` flags will: + +1. Look up the subordinate address in `hub_nodes` +2. Open a binkp session to `inet_host`/`port` if set, otherwise to the nodelist-resolved host (nodes only — points typically have no independently routable host and are pull-only) +3. Deliver all pending `hub_node_outbound` packets for that subordinate +4. Accept any inbound mail the subordinate wants to send us + +Push scheduling is handled by the existing binkp scheduler (`src/Binkp/Connection/Scheduler.php`) — hub subordinates can be added to the schedule alongside uplinks. In practice, push is mainly useful for node-type downlinks with a known host; most points will be pull-only. + +--- + +## Point-Specific Session Handling + +A handful of behaviors are point-only and layer on top of the shared binkp/queue mechanics above: + +- **Boss/point authentication**: a point authenticates with its full `boss.point` address and `session_password`, looked up the same way as any other `hub_nodes` row — no separate authentication path is required. +- **Capability flags**: `hub_nodes.capability_flags` records informational flags such as CM (Continuous Mail) / ICM (Intermittent, requests-only). These are advisory for now — used for admin display and to decide default push/pull eligibility (an ICM point defaults to pull-only) — not enforced as hard session-negotiation logic in this phase. +- **Point number allocation**: the admin UI should suggest the next unused `point_number` for a chosen `boss_address` rather than requiring the sysop to track allocations manually. +- **Quotas**: `max_packet_kb` and `queue_retention_days` (already shared columns) act as the point's mail quota controls; no additional point-specific quota table is needed at this phase. + +--- + +## Admin Interface + +### Hub nodes list page (`/admin/hub`) + +- Table of all configured subordinates (nodes and points, filterable by type) with address, name, status, last session, queue depth +- Add / Edit / Delete +- Quick toggle: enable/disable, hold mail +- Add flow: choose type (Node vs Point) first — Node prompts for a free-text FTN address; Point prompts for a boss AKA (picked from our configured AKAs) plus a point number (with a suggested next-available default) + +### Subordinate detail / area subscriptions + +- Checklist of all echo areas with current subscription status per subordinate +- Bulk subscribe/unsubscribe +- Per-area pause toggle +- Current queue depth and oldest pending item + +### Outbound queue view + +- Filterable table: subordinate, type, status, age, size +- Actions: retry failed items, purge old sent items, hold/release + +--- + +## TIC / File Area Distribution + +Phases 1-3 cover echomail and netmail only. File areas are a separate distribution mechanism entirely — files are announced via **TIC** (File Information Control, FSC-0087) files, not FTS-0001 `.pkt` packets — and today that mechanism only runs in the uplink direction. `FileAreaManager::uploadFile()`/`uploadFileFromPath()` call `TicFileGenerator::createTicFilesForUplinks()` (`src/TicFileGenerator.php:3357` call site in `FileAreaManager.php`), which loops over `BinkpConfig::getUplinksForDomain()` and has no knowledge of `hub_nodes` at all. A file uploaded to a networked file area is therefore never forwarded to a registered downlink or point, and a TIC file received from an uplink or a downlink is never relayed onward to other downlinks either. + +This phase extends the same hub-distribution pattern already used for echomail — per-subordinate subscriptions, a queue table, push/pull delivery over the existing binkp session — to file areas. + +### Per-downlink file area subscriptions: `hub_node_fileareas` + +Mirrors `hub_node_areas` exactly, one row per (downlink, file area) pair: + +```sql +CREATE TABLE hub_node_fileareas ( + id SERIAL PRIMARY KEY, + hub_node_id INTEGER NOT NULL REFERENCES hub_nodes(id) ON DELETE CASCADE, + file_area_id INTEGER NOT NULL REFERENCES file_areas(id) ON DELETE CASCADE, + paused BOOLEAN NOT NULL DEFAULT FALSE, + subscribed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (hub_node_id, file_area_id) +); + +CREATE INDEX idx_hub_node_fileareas_node ON hub_node_fileareas (hub_node_id); +CREATE INDEX idx_hub_node_fileareas_filearea ON hub_node_fileareas (file_area_id); +``` + +Managed the same way as echo area subscriptions: a checklist per downlink in **Admin → Downlinks**, bulk subscribe/unsubscribe, per-area pause. + +### Queueing TIC deliveries + +A TIC delivery is a *pair* of files — the `.tic` control file and the actual data file — not a single `.pkt` blob, so it doesn't fit `hub_node_outbound`'s existing `packet_data` column unchanged. Rather than a parallel queue table (which would fragment the Downlink Queue viewer across two sources), `hub_node_outbound` gains two new nullable columns and a third `message_type`: + +```sql +ALTER TABLE hub_node_outbound + ADD COLUMN file_id INTEGER REFERENCES files(id) ON DELETE SET NULL, + ADD COLUMN tic_file_data BYTEA, -- the actual file payload (tic-only) + ADD COLUMN tic_filename VARCHAR(255); -- original filename (tic-only) + +ALTER TABLE hub_node_outbound + DROP CONSTRAINT chk_hno_message_type, + ADD CONSTRAINT chk_hno_message_type CHECK (message_type IN ('echomail', 'netmail', 'tic')); +``` + +For `message_type = 'tic'`, `packet_data` holds the generated `.tic` control file content (built the same way `TicFileGenerator::buildTicContent()` already does, addressed to the downlink instead of an uplink) and `tic_file_data`/`tic_filename` hold the file payload and its name. `echoarea_id`/`echomail_id`/`netmail_id` stay `NULL` for TIC rows; `file_id` is the new, TIC-only counterpart. + +### Fanout algorithm + +Mirrors `HubFanout::fanout()`: + +``` +For each new file F in file area A (upload, or inbound TIC storage): + 1. Fetch all active hub_nodes subscribed to A via hub_node_fileareas + (enabled=true, paused=false, hold_mail=false) + 2. For each subscriber D: + a. Skip D if D's address is already in the TIC's Seenby field + (mirrors SEEN-BY loop prevention for echomail) + b. Build a .tic control file addressed to D.node_address, with our + address appended to Path and Seenby (same as the existing + uplink-direction TIC content, just re-addressed) + c. INSERT into hub_node_outbound (hub_node_id=D, message_type='tic', + file_id=F, packet_data=, tic_file_data=, + tic_filename=, status='pending') +``` + +Called from the same site `HubFanout::fanout()` is called from `storeEchomail()` — a new `HubFanout::fanoutFile()` (or a sibling `HubTicFanout` class, consistent with keeping `HubFanout` scoped to echomail) invoked from `FileAreaManager::uploadFile()`/`uploadFileFromPath()` immediately after the existing `createTicFilesForUplinks()` call, and from wherever inbound TIC processing stores a received file. + +### Relay up to the uplink + +Same principle as the Phase 3 echomail-uplink-relay fix: a file received from a downlink/point should also be forwarded up to the file area's configured uplink (via the existing `createTicFilesForUplinks()`), unless it was received *from* that same uplink or the uplink already appears in the TIC's Seenby field — the same two loop guards already used for echomail's uplink relay (`BinkdProcessor::relayEchomailToUplinkIfNeeded()`). + +### Delivery + +Reuses the existing binkp session and `hub_node_outbound` polling/serving mechanics (`BinkpSession::sendHubNodeOutbound()`, `BinkpClient::pollAllHubNodes()`, `Scheduler::runScheduledHubNodePush()`) — those already iterate `hub_node_outbound` by status without caring what `message_type` a row has. The only new work is on the file-transfer side: sending a `message_type='tic'` row means transferring *two* files (the `.tic` control file, then the data file named `tic_filename`) instead of one `.pkt`, matching how a real binkd/tosser session sends a TIC pair. + +### Points and file areas + +Unlike echomail/netmail, most point software has no file-area management of its own — a `boss.point` system typically isn't running a second file area engine. `hub_node_fileareas` and the fanout logic apply identically to both node and point subordinates for consistency with the rest of the schema, but in practice this feature is expected to be used almost entirely with node-type downlinks; nothing prevents subscribing a point if its software does support TIC/file-echo processing. + +### Out of scope for this phase + +- **Filefix** (self-service file-area subscription management via netmail, the file-area analog of Areafix) — deferred to [Areafix / FileFix (Future Phase)](#areafix--filefix-future-phase), same as Areafix. +- **Duplicate/collision handling across the whole downlink set** (e.g. deduplicating an identical file already queued for multiple subordinates) — each subscriber gets its own independently-generated TIC row, same granularity as `hub_node_outbound` already uses for echomail. + +--- + +## Areafix / FileFix (Future Phase) + +Areafix is a netmail robot that allows remote sysops (or point owners) to manage their own echo subscriptions by sending netmail to a well-known address (typically `Areafix` or `Echofix` at one of our AKAs). Filefix is the equivalent for file area subscriptions, typically sent to `Filefix`. Note that BinktermPHP already has client-side Areafix support for *sending* commands to its own uplinks (`src/AreaFixManager.php`); this phase is the server-side counterpart — responding to Areafix/Filefix commands sent to us by our own subordinates. + +### Commands (standard) + +| Command | Effect | Applies to | +|---|---|---| +| `+GENERAL` | Subscribe to GENERAL | Areafix (echo areas) | +| `-COOKING` | Unsubscribe from COOKING | Areafix (echo areas) | +| `+CDROM_TAG` | Subscribe to a file area | Filefix (file areas) | +| `-CDROM_TAG` | Unsubscribe from a file area | Filefix (file areas) | +| `%LIST` | Reply with available areas | Both | +| `%QUERY` | Reply with currently subscribed areas | Both | +| `%HELP` | Reply with command reference | Both | +| `%PAUSE` | Pause all areas (hold mail) | Both | +| `%RESUME` | Resume all areas | Both | +| `%RESCAN [AREATAG] [days]` | Re-queue echomail history (all subscribed areas, or one) | Areafix (echo areas) only | + +### Design notes (Implemented) + +- The `hub_node_areas.paused`, `hub_node_fileareas.paused`, and `hub_nodes.hold_mail` columns support Areafix/Filefix PAUSE/RESUME as planned — `%PAUSE`/`%RESUME` toggle `hub_nodes.hold_mail` (whole-subordinate, matching the table's granularity; there's no per-command way to pause a single area). +- Subscriptions written by `+TAG`/`-TAG` go through the same `HubNodeManager::setAreaSubscription()`/`setFileAreaSubscription()` methods the admin UI checklist uses, so Areafix/Filefix is just an automated writer to `hub_node_areas`/`hub_node_fileareas` as planned. +- The robot applies identically to nodes and points; it's implemented as `src/Hub/HubAreafixProcessor.php`, hooked into `BinkdProcessor::storeNetmail()`, resolving the sender against `hub_nodes.node_address` (`getByAddress()`) — not a "configurable robot name" as originally sketched; the robot names are fixed as `AreaFix`/`FileFix` (case-insensitive `to_name` match), matching the fixed names the client-side `AreaFixManager` already sends to. +- **Robot names and passwords, not in the original sketch**: `AreaFix` and `FileFix` are two separate netmail addresses (not one combined robot), each authenticated by its own new `hub_nodes.areafix_password`/`filefix_password` column rather than the existing `session_password` — see [Implementation Status](#implementation-status) for the reasoning. +- **Area eligibility, not in the original sketch**: `+TAG` only succeeds for areas eligible for self-subscription — active, non-local echoareas (excluding sysop-only), and active, non-local, non-private file areas. This reuses the same filter as the admin subscription checklists rather than a new per-subordinate allowlist table. +- **`%RESCAN`, not in the original sketch**: added after Phase 5 shipped, matching a common areafix convention (e.g. Mystic) that the original sketch didn't cover. See the Phase 5 implementation note above and `HubFanout::rescanForNode()`. + +--- + +## Out of Scope + +- **Open relay / full third-party transit routing** — routing mail for addresses not registered in `hub_nodes` (the earlier `PointRouting_Proposal.md` "Approach B") is explicitly excluded. This system only distributes to subordinates that are explicitly registered, whether node or point. +- **QWK or other alternate transports for subordinates** — the earlier `Downlink_Distribution_Proposal.md` sketched an optional QWK transport for downlinks. Binkp is the only transport covered here; a `transport` column could be added later without schema conflict if that need materializes. +- **Automatic point provisioning from user accounts** — points are registered by the sysop via the admin UI in this phase; self-service "request a point" flows are a possible future extension, not part of this proposal. + +--- + +## Implementation Plan + +### Phase 1 — Core infrastructure — **Done** (branch `hubpoint`, committed `36c4ab8d`) + +1. [x] Database migrations: `hub_nodes`, `hub_node_areas`, `hub_node_outbound` +2. [x] `HubFanout` class — fanout logic with SEEN-BY/PATH handling, point-aware branching +3. [x] `EchomailSeenBy` helper class, including `isPointAddress()` +4. [x] Hook fanout into packet processor (inbound echomail) and local post path +5. [x] Admin: hub nodes CRUD (node + point add flows) and area subscription UI + +### Phase 2 — Delivery — **Done** (branch `hubpoint`, committed `7ad64d70`) + +6. [x] Binkp server: authenticate subordinates from `hub_nodes`; serve `hub_node_outbound` +7. [x] Push delivery for node-type subordinates with a routable host — implemented as `--all-hub-nodes` on `scripts/binkp_poll.php` plus automatic hub-node resolution inside `BinkpClient::connect()` (a plain positional address now works for hub nodes too; no separate `--hub-node=` flag was added since it would duplicate what `connect()` already does — see [Modified Files](#modified-files)) +8. [x] Scheduler integration for push delivery — `Scheduler::runScheduledHubNodePush()` + +### Phase 3 — Netmail routing — **Done** (branch `hubpoint`, committed `0e4c6a7f`; follow-on fixes `4ddc552b`, `b17f0398`) + +9. [x] Netmail passthrough routing to nodes and points, gated by `allow_inbound_netmail` (the `HUB_ROUTE_NETMAIL` global flag from the original design was implemented, then removed — see [Implementation Status](#implementation-status)) — `src/Hub/HubNetmailRouter.php` + +### Phase 4 — TIC / File Area Distribution — **Done** (branch `hubpoint`) + +10. [x] Database migration: `hub_node_fileareas`; `hub_node_outbound` gains `file_id`, `tic_file_data`, `tic_filename` and its `message_type` check constraint gains `'tic'` +11. [x] Fanout logic (`HubFanout::fanoutFile()`) — subscriber lookup via `hub_node_fileareas`, Seenby-based loop prevention, TIC content generation addressed to each downlink +12. [x] Hook fanout into `FileAreaManager::uploadFile()`/`uploadFileFromPath()` (alongside the existing `createTicFilesForUplinks()` call) and inbound TIC processing +13. [x] Uplink relay for files received from a downlink/point — implemented as a loop guard inside `TicFileGenerator::createTicFilesForUplinks()` itself (see Implementation Status) rather than a separate relay method, since that single call site is shared by both the local-upload and inbound-TIC-relay cases +14. [x] Admin: file area subscription UI per downlink (mirrors echo area subscription UI) +15. [x] Delivery: extend `BinkpSession::sendHubNodeOutbound()` to send a TIC pair (control file + data file) for `message_type='tic'` rows instead of a single `.pkt` +16. [x] Downlink Queue viewer: display and inspect `tic` rows alongside `echomail`/`netmail` + +### Phase 5 — Areafix / FileFix (separate proposal) — **Done** (branch `hubpoint`) + +17. [x] Areafix/Filefix robot and netmail command parser (server-side) — `src/Hub/HubAreafixProcessor.php`, hooked into `BinkdProcessor::storeNetmail()` +18. [x] Reply netmail generation — queued into `hub_node_outbound` via `HubNetmailRouter::buildAndEnqueue()` +19. [x] Per-subordinate access controls — implemented as a shared eligibility filter (active, non-local areas; echoareas also exclude sysop-only, file areas also exclude private) rather than a new per-subordinate allowlist table; see [Implementation Status](#implementation-status) +20. [x] `%RESCAN [AREATAG] [days]` — re-queue echomail history into `hub_node_outbound`, added after Phase 5 shipped; `HubFanout::rescanForNode()`, `HubNodeManager::getSubscribedEchoareaIds()`/`findSubscribedEchoareaByTag()` + +--- + +## New Files + +| File | Purpose | Status | +|---|---|---| +| `src/Hub/HubNodeManager.php` | CRUD for hub nodes/points and area subscriptions | Done (Phase 1) | +| `src/Hub/HubFanout.php` | Echomail fanout engine (point-aware) | Done (Phase 1) | +| ~~`src/Hub/HubDelivery.php`~~ | Superseded — no separate delivery class was built. Packet materialization (temp-file write from the BYTEA blob, then reuse of the existing `sendFile()`) lives directly in `BinkpSession::sendHubNodeOutbound()`, and queue-selection for push lives in `BinkpClient::pollAllHubNodes()` / `Scheduler::runScheduledHubNodePush()` — this matched the existing `sendFreqFiles()`/`sendHoldFiles()` pattern closely enough that a new class would have been a redundant thin wrapper | Not built (by design, Phase 2) | +| `src/Echomail/EchomailSeenBy.php` | SEEN-BY / PATH parse, check, update, format; point-address detection | Done (Phase 1) | +| `database/migrations/v20260808003922_create_hub_nodes_table.sql` | | Done (Phase 1) | +| `database/migrations/v20260808003926_create_hub_node_areas_table.sql` | | Done (Phase 1) | +| `database/migrations/v20260808003927_create_hub_node_outbound_table.sql` | | Done (Phase 1) | +| `templates/admin/hub_nodes.twig` | Admin hub nodes list, add/edit modal, and area-subscription modal (single-page pattern, mirroring `templates/admin/networks.twig` — no separate edit template was needed) | Done (Phase 1) | +| ~~`templates/admin/hub_node_edit.twig`~~ | Superseded — folded into `hub_nodes.twig`'s modal instead of a separate page | Not built (by design) | +| ~~`routes/admin-hub-routes.php`~~ | Superseded — hub routes were added directly to `routes/admin-routes.php` (`/admin/hub-nodes` page route + `/admin/api/hub-nodes*` REST routes), matching how `/networks` is handled rather than splitting into a new route file | Not built (by design) | +| `src/Hub/HubNetmailRouter.php` | `routeIfHubNode()`: inbound transit netmail into `hub_node_outbound`, gated by per-node `allow_inbound_netmail` (the global `HUB_ROUTE_NETMAIL` flag was removed - see Implementation Status). `routeOutboundIfHubNode()`: locally-composed netmail addressed to a registered downlink, gated by per-node `allow_outbound`. `relayIfFromHubNode()`: netmail from a registered point addressed elsewhere, relayed via the normal uplink-routing outbound queue, gated by per-node `allow_inbound_netmail`. All three share a private `buildAndEnqueue()`/temp-file pattern for packet build + insert, and add a `skip_default_pid_tearline` flag plus a Via-line hop (see `src/functions.php`'s `generateViaLine()`) when relaying preserved kludges. | Done (Phase 3) | +| `database/migrations/v20260808164447_create_hub_node_fileareas_table.sql` | `hub_node_fileareas` table (mirrors `hub_node_areas`) | Done (Phase 4) | +| `database/migrations/v20260808164452_add_tic_columns_to_hub_node_outbound.sql` | Adds `file_id`, `tic_file_data`, `tic_filename` and widens the `message_type` check constraint to include `'tic'` | Done (Phase 4) | +| ~~`src/Hub/HubFileAreaFanout.php`~~ | Superseded — file-area fanout landed as `HubFanout::fanoutFile()` on the existing `HubFanout` class instead of a sibling file, matching how the class already mixes echomail-fanout entry points | Not built (by design, Phase 4) | +| `database/migrations/v20260808213648_add_areafix_filefix_passwords_to_hub_nodes.sql` | Adds `hub_nodes.areafix_password`/`filefix_password` | Done (Phase 5) | +| `src/Hub/HubAreafixProcessor.php` | Areafix/Filefix robot: detects `AreaFix`/`FileFix`-addressed netmail, authenticates via the new password columns, parses `+TAG`/`-TAG`/`%LIST`/`%QUERY`/`%HELP`/`%PAUSE`/`%RESUME`/`%RESCAN`, and queues a reply | Done (Phase 5) | + +## Modified Files + +| File | Change | Status | +|---|---|---| +| `src/BinkdProcessor.php` (the actual inbound packet processing entry point; `src/Binkp/PacketProcessor.php` does not exist) | Call `HubFanout::fanout()` in `storeEchomail()` right after the message-count update, guarded by `if ($newId > 0)`. Also gained a `skip_default_seenby_path` opt-out flag on `writeMessage()` so `HubFanout` can supply its own merged SEEN-BY/PATH without the existing single-hop auto-synthesis duplicating it (default behavior for all other callers unchanged). `storeNetmail()` gained a single-line hook at the top calling `HubNetmailRouter::routeIfHubNode()` before the existing FREQ-intercept check — zero structural change to the rest of the method. | Done (Phase 1 echomail hook; Phase 3 netmail hook) | +| `src/Binkp/Protocol/BinkpSession.php` | M_ADR address-matching loop also checks `HubNodeManager::getByAddress()`, not just uplinks (fixes a real bug where a hub node/point offering multiple AKAs could get the wrong one selected). `getPasswordForRemote()` falls back to a hub node's `session_password` when no uplink matches (covers both plaintext and CRAM-MD5, which both funnel through this method). New `sendHubNodeOutbound()`, modeled on `sendFreqFiles()`/`sendHoldFiles()`, called from `processSession()` for both originator and answerer roles — serves pending `hub_node_outbound` rows to the authenticated peer by address, marking each `sent`/`failed`. | Done (Phase 2) | +| `src/Binkp/Protocol/BinkpClient.php` | `connect()`'s existing non-uplink fallback chain now checks `HubNodeManager::getByAddress()` (using `inet_host`/`port`/`session_password`) before falling to nodelist/DNS resolution — this is what makes push delivery work through the *existing* address-based call paths with no protocol/IPC changes. New `pollAllHubNodes()`, modeled on `pollAllUplinks()`. | Done (Phase 2) | +| `scripts/binkp_poll.php` | Added `--all-hub-nodes` flag. No separate `--hub-node=
` flag — the existing plain positional-address path already calls `connect()` unchanged, which now resolves hub nodes automatically. | Done (Phase 2, deviates from the original `--hub-node`/`--all-hub-nodes` wording — see Phase 2 checklist note) | +| `src/Binkp/Connection/Scheduler.php` | Added `runScheduledHubNodePush()`, modeled directly on `runScheduledCrashmailPoll()` (same interval-gated check-and-trigger shape, `HUB_PUSH_POLL_INTERVAL = 300`s), called from `runDaemon()`'s loop. Queries for push-eligible hub nodes with pending `hub_node_outbound` rows and triggers `AdminDaemonClient::binkPoll($address)` per address — the same IPC path already used for uplinks. No admin-daemon changes needed. | Done (Phase 2) | +| `routes/admin-routes.php` | Added `/admin/hub-nodes` page route and `/admin/api/hub-nodes` REST routes (list/create/update/delete, area subscriptions, next-point-number lookup) | Done (Phase 1) | +| `src/MessageHandler.php` | Added `fanoutToHubNodes()`, called from `postEchomail()` and `approveEchomail()` alongside the existing `spoolOutboundEchomail()` call (reuses the existing moderation gate). `spoolOutboundNetmail()` now checks `HubNetmailRouter::routeOutboundIfHubNode()` before uplink routing (bug fix — see Phase 3 status above). | Done (Phase 1 + Phase 3 bug fix) | +| `templates/base.twig`, `templates/shells/web/base.twig`, `templates/shells/bbs-menu/base.twig` | Added a "Downlinks" nav entry next to "Networks" (labeled "Downlinks", not "Hub Nodes" — see the Admin UI labeling note above) | Done (Phase 1, relabeled after initial ship) | +| `config/i18n/{de,en,es,fr,it,ru}/{common,errors}.php` | Added `ui.admin.hub_nodes.*` / `errors.admin.hub_nodes.*` keys (values display as "Downlink(s)") | Done (Phase 1, relabeled after initial ship) | +| `docs/CONFIGURATION.md` | ~~Documented `HUB_ROUTE_NETMAIL` in the `.env` Miscellaneous section~~ — entry added then removed once the flag itself was removed (see Implementation Status) | Done (Phase 3, later reverted) | +| `src/FileAreaManager.php` | `finalizeApprovedUserUpload()` (the single call site both `uploadFile()`/`uploadFileFromPath()` funnel through) calls `HubFanout::fanoutFile()` right after the existing `createTicFilesForUplinks()` call, inside the same `is_local`/`is_private` guard | Done (Phase 4) | +| `src/TicFileGenerator.php` | `buildTicContent()`'s field-building logic extracted into a shared private `buildTicLines()`, reused by a new public `buildTicContentForDownlink()` that takes caller-supplied Path/Seenby address lists instead of the uplink path's single-hop defaults. `createTicFilesForUplinks()` also gained a per-uplink `uplinkAlreadyHasFile()` loop guard (checks `uploaded_from_address` and `tic_seenby` against each uplink) so the same call now safely relays inbound-TIC files without looping | Done (Phase 4) | +| `src/Hub/HubFanout.php` | New `fanoutFile()` entry point (plus `loadFile()`/`loadFileArea()`/`queueFileForSubscriber()` and small address-list helpers) alongside the existing echomail `fanout()` | Done (Phase 4) | +| `src/Hub/HubNodeManager.php` | New `getFileAreaSubscriptions()`, `setFileAreaSubscription()`, `bulkSetFileAreaSubscriptions()`, `getSubscribersForFileArea()` mirroring the existing echoarea-subscription methods | Done (Phase 4) | +| `scripts/process_packets.php` | `processInboundTicFiles()` calls `HubFanout::fanoutFile()` and `TicFileGenerator::createTicFilesForUplinks()` after a successful, non-duplicate inbound TIC store | Done (Phase 4) | +| `src/Binkp/Protocol/BinkpSession.php` | `sendHubNodeOutbound()` branches on `message_type='tic'` into a new `sendHubNodeTicRow()`: writes the data file (original filename) and `.tic` control file to a per-row temp directory and sends the data file first, then the control file | Done (Phase 4) | +| `src/Binkp/Web/BinkpController.php` | `inspectHubOutboundPacket()`/`getHubOutboundPacketBytes()` branch on `message_type='tic'` — inspecting parses the TIC control text into fields instead of treating it as a `.pkt` (new `inspectHubOutboundTicRow()`), and downloading returns the referenced data file instead of the control text. New shared `fetchHubOutboundRow()` replaces the old packet-data-only fetch | Done (Phase 4) | +| `routes/admin-routes.php` | Added `/admin/api/hub-nodes/{id}/fileareas` GET/PUT routes, mirroring the existing `/areas` routes | Done (Phase 4) | +| `templates/admin/hub_nodes.twig` | Second per-downlink subscription modal/checklist for file areas, mirroring the echo area one | Done (Phase 4) | +| `templates/binkp.twig` | Downlink Queue viewer displays and inspects `tic` rows alongside `echomail`/`netmail`; the packet inspector modal renders a TIC-fields view instead of a `.pkt` header/message view when inspecting a `tic` row | Done (Phase 4) | +| `docs/Downlinks.md` | Added "File area (TIC) distribution" section; updated area-subscriptions, delivery, and queue-monitoring sections to mention file areas/TIC pairs/`tic` rows | Done (Phase 4) | +| `config/i18n/{de,en,es,fr,it,ru}/{common,errors}.php` | Added `ui.admin.hub_nodes.fileareas.*`, `errors.admin.hub_nodes.fileareas_*_failed`, and `ui.binkp.hub_outbound.tic_*` keys (Phase 4); added `ui.admin.hub_nodes.areafix_password`/`filefix_password` (+ `_help`) keys (Phase 5) | Done (Phase 4, Phase 5) | +| `src/BinkdProcessor.php` | `storeNetmail()` gained a new first check calling `HubAreafixProcessor::processIncoming()`, ahead of the existing `HubNetmailRouter::routeIfHubNode()` hook | Done (Phase 5) | +| `src/Hub/HubNetmailRouter.php` | `buildAndEnqueue()` widened from `private` to `public` so `HubAreafixProcessor` can reuse it to queue robot replies | Done (Phase 5) | +| `src/Hub/HubNodeManager.php` | `COLUMNS`, `prepareFields()`, and the `create()`/`update()` SQL gained `areafix_password`/`filefix_password` | Done (Phase 5) | +| `templates/admin/hub_nodes.twig` | Add/edit modal gained AreaFix Password / FileFix Password fields, following the existing session/packet password pattern (sent only when non-empty; never pre-filled on edit) | Done (Phase 5) | +| `docs/Downlinks.md` | Added an "AreaFix / FileFix" section; updated the field table; removed the stale "Areafix not yet implemented" limitation; later updated again for `%RESCAN` | Done (Phase 5) | +| `src/Hub/HubFanout.php` | New `rescanForNode()` (plus `loadRecentMessages()`); `queueForSubscriber()`'s packet-build/insert logic split out into a shared `buildAndQueuePacket()` so both it and `rescanForNode()` can reuse it - `rescanForNode()` calls it directly with the message's original `bottom_kludges`, bypassing the SEEN-BY loop-guard/mutation `queueForSubscriber()` applies for live fanout | Done (Phase 5, `%RESCAN` addendum) | +| `src/Hub/HubNodeManager.php` | New `getSubscribedEchoareaIds()` and `findSubscribedEchoareaByTag()`, backing `%RESCAN`'s all-areas and single-area forms respectively | Done (Phase 5, `%RESCAN` addendum) | + +Exact packet-processing entry point and outbound file layout should be confirmed against current `src/Binkp/` code at implementation time, since the two source proposals disagreed on class names in places. (Resolved during Phase 1: the entry point is `src/BinkdProcessor.php`, not `src/Binkp/PacketProcessor.php`.) + +--- + +## Decisions + +1. **One table, two types.** `hub_nodes.node_type` distinguishes node vs point rather than separate tables, since queueing, admin UI, and binkp auth are otherwise identical. +2. **Boss address must be one of our own AKAs.** Enforced at the admin UI level (picker, not free text) rather than a DB foreign key, since AKAs live in `config/binkp.json`, not a database table. +3. **Points never appear in SEEN-BY/PATH.** This matches FTS conventions and avoids inventing a non-standard extension to SEEN-BY. +4. **Inet host for push**: `hub_nodes` includes `inet_host`/`port`. If set, push delivery uses them directly; nodes without them fall back to nodelist lookup. Points generally have neither and are pull-only. +5. **Packet format**: Type-2+ (FSP-1010) for all hub-distributed packets. No per-subordinate option needed. +6. **Queue retention**: configurable per subordinate, default 30 days. Sent rows older than the retention period are purged by a maintenance task. +7. **Bounce netmail**: no bounce. Mail for a held or disabled subordinate sits in the queue until the hold is lifted or the retention period expires, matching standard FTN behaviour. +8. **Inbound echomail/netmail from subordinates**: configurable per subordinate via `allow_inbound_echomail` / `allow_inbound_netmail` (both default `true`). +9. **Full third-party transit is excluded** from this phase; see [Out of Scope](#out-of-scope). +10. **TIC deliveries reuse `hub_node_outbound` rather than a parallel queue table** (Phase 4). A TIC delivery is a control-file/data-file pair, not a single `.pkt`, so the table gains nullable `file_id`/`tic_file_data`/`tic_filename` columns used only when `message_type='tic'`. This keeps one queue table and one Downlink Queue viewer for every distribution type instead of fragmenting delivery/monitoring across two tables for what is, from the admin's point of view, the same kind of queue. +11. **Areafix/Filefix passwords are dedicated columns, not the session password** (Phase 5). `hub_nodes.areafix_password`/`filefix_password` are separate from `session_password` so a subordinate's robot credential can be rotated or disabled independently of its binkp session authentication. +12. **Areafix/Filefix are two robots, not one** (Phase 5). `AreaFix` manages echoareas, `FileFix` manages file areas — matching the existing client-side `AreaFixManager` naming rather than inventing a combined robot that would need to disambiguate tag namespaces. +13. **Areafix/Filefix self-subscription eligibility reuses the admin checklist filter, not a new allowlist table** (Phase 5). A subordinate can `+TAG` any area the admin UI's own subscription checklist would show it (active, non-local; echoareas also exclude sysop-only, file areas also exclude private) — no separate per-subordinate "areas you're allowed to request" table. + +--- + +## Open Questions + +1. Should points have web UI account access in addition to BinkP, or BinkP-only? +2. What happens when a point or downlink is unreachable for an extended period — is there a max queue age before mail is dropped, beyond the existing `queue_retention_days` purge? +3. Should point/downlink message quotas be enforced (not just advisory `max_packet_kb`)? +4. Does capability-flag negotiation (CM/ICM) need to affect scheduling logic in a later phase, or does the advisory-only treatment in this proposal suffice long-term? + +--- + +## References + +- FTS-0001: Basic FidoNet Technical Standard +- FTS-0005: The distribution nodelist +- FSC-0087: TIC file format for file distribution +- `docs/proposals/Downlink_Distribution_Proposal.md` (superseded by this document) +- `docs/proposals/PointRouting_Proposal.md` (superseded by this document, Approach A only) + +--- + +**Document Status:** Draft Proposal — All five phases implemented (branch `hubpoint`; Phase 1 committed `36c4ab8d`, Phase 2 committed `7ad64d70`, Phase 3 committed `0e4c6a7f`/`4ddc552b`/`b17f0398`, Phases 4-5 not yet committed) +**Last Updated:** 2026-08-08 +**Author:** AI-Generated (Requires Review) diff --git a/docs/proposals/PointRouting_Proposal.md b/docs/proposals/PointRouting_Proposal.md index 5ed2d9ee9..c93eed37e 100644 --- a/docs/proposals/PointRouting_Proposal.md +++ b/docs/proposals/PointRouting_Proposal.md @@ -2,6 +2,10 @@ --- +**SUPERSEDED:** This proposal has been unified with `docs/proposals/Downlink_Distribution_Proposal.md` into `docs/proposals/HubPointSystemJuly2026.md`. Only this document's Approach A (point-only mode) was carried forward; Approach B (full transit/open relay routing) was explicitly excluded from the unified proposal. Refer to `docs/proposals/HubPointSystemJuly2026.md` for current design work. + +--- + **DRAFT DOCUMENT** This proposal is a draft document generated by AI and may not have been reviewed for accuracy. The technical details, implementation estimates, and architectural decisions described herein should be validated by experienced developers familiar with FidoNet protocols and the BinktermPHP codebase before implementation. diff --git a/routes/admin-routes.php b/routes/admin-routes.php index 4f83227b1..111732d80 100644 --- a/routes/admin-routes.php +++ b/routes/admin-routes.php @@ -393,6 +393,13 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array ]); }); + SimpleRouter::get('/hub-nodes', function() { + RouteHelper::requireAdmin(); + + $template = new Template(); + $template->renderResponse('admin/hub_nodes.twig'); + }); + // Webdoors config page SimpleRouter::get('/webdoors', function() { $user = RouteHelper::requireAdmin(); @@ -3399,6 +3406,179 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array } }); + SimpleRouter::get('/hub-nodes', function() { + $auth = new Auth(); + $user = $auth->requireAuth(); + + $adminController = new AdminController(); + $adminController->requireAdmin($user); + + header('Content-Type: application/json'); + + try { + $manager = new \BinktermPHP\Hub\HubNodeManager(); + echo json_encode([ + 'success' => true, + 'hub_nodes' => $manager->getAll(), + 'akas' => $manager->getConfiguredAkas(), + ]); + } catch (Throwable $e) { + apiError('errors.admin.hub_nodes.load_failed', apiLocalizedText('errors.admin.hub_nodes.load_failed', 'Failed to load hub nodes'), 500); + } + }); + + SimpleRouter::post('/hub-nodes', function() { + $auth = new Auth(); + $user = $auth->requireAuth(); + + $adminController = new AdminController(); + $adminController->requireAdmin($user); + + header('Content-Type: application/json'); + + try { + $payload = json_decode(file_get_contents('php://input'), true); + $manager = new \BinktermPHP\Hub\HubNodeManager(); + $hubNode = $manager->create(is_array($payload) ? $payload : []); + echo json_encode(['success' => true, 'hub_node' => $hubNode, 'message_code' => 'ui.admin.hub_nodes.saved']); + } catch (Throwable $e) { + apiError('errors.admin.hub_nodes.save_failed', $e->getMessage(), 400); + } + }); + + SimpleRouter::put('/hub-nodes/{id}', function($id) { + $auth = new Auth(); + $user = $auth->requireAuth(); + + $adminController = new AdminController(); + $adminController->requireAdmin($user); + + header('Content-Type: application/json'); + + try { + $payload = json_decode(file_get_contents('php://input'), true); + $manager = new \BinktermPHP\Hub\HubNodeManager(); + $hubNode = $manager->update((int)$id, is_array($payload) ? $payload : []); + echo json_encode(['success' => true, 'hub_node' => $hubNode, 'message_code' => 'ui.admin.hub_nodes.saved']); + } catch (Throwable $e) { + apiError('errors.admin.hub_nodes.save_failed', $e->getMessage(), 400); + } + }); + + SimpleRouter::delete('/hub-nodes/{id}', function($id) { + $auth = new Auth(); + $user = $auth->requireAuth(); + + $adminController = new AdminController(); + $adminController->requireAdmin($user); + + header('Content-Type: application/json'); + + try { + $manager = new \BinktermPHP\Hub\HubNodeManager(); + $manager->delete((int)$id); + echo json_encode(['success' => true, 'message_code' => 'ui.admin.hub_nodes.deleted']); + } catch (Throwable $e) { + apiError('errors.admin.hub_nodes.delete_failed', $e->getMessage(), 400); + } + }); + + SimpleRouter::get('/hub-nodes/{id}/areas', function($id) { + $auth = new Auth(); + $user = $auth->requireAuth(); + + $adminController = new AdminController(); + $adminController->requireAdmin($user); + + header('Content-Type: application/json'); + + try { + $manager = new \BinktermPHP\Hub\HubNodeManager(); + echo json_encode(['success' => true, 'areas' => $manager->getAreaSubscriptions((int)$id)]); + } catch (Throwable $e) { + apiError('errors.admin.hub_nodes.areas_load_failed', apiLocalizedText('errors.admin.hub_nodes.areas_load_failed', 'Failed to load area subscriptions'), 500); + } + }); + + SimpleRouter::put('/hub-nodes/{id}/areas', function($id) { + $auth = new Auth(); + $user = $auth->requireAuth(); + + $adminController = new AdminController(); + $adminController->requireAdmin($user); + + header('Content-Type: application/json'); + + try { + $payload = json_decode(file_get_contents('php://input'), true); + $echoareaIds = is_array($payload) && isset($payload['echoarea_ids']) && is_array($payload['echoarea_ids']) + ? array_map('intval', $payload['echoarea_ids']) + : []; + $manager = new \BinktermPHP\Hub\HubNodeManager(); + $manager->bulkSetAreaSubscriptions((int)$id, $echoareaIds); + echo json_encode(['success' => true, 'areas' => $manager->getAreaSubscriptions((int)$id), 'message_code' => 'ui.admin.hub_nodes.areas_saved']); + } catch (Throwable $e) { + apiError('errors.admin.hub_nodes.areas_save_failed', $e->getMessage(), 400); + } + }); + + SimpleRouter::get('/hub-nodes/{id}/fileareas', function($id) { + $auth = new Auth(); + $user = $auth->requireAuth(); + + $adminController = new AdminController(); + $adminController->requireAdmin($user); + + header('Content-Type: application/json'); + + try { + $manager = new \BinktermPHP\Hub\HubNodeManager(); + echo json_encode(['success' => true, 'fileareas' => $manager->getFileAreaSubscriptions((int)$id)]); + } catch (Throwable $e) { + apiError('errors.admin.hub_nodes.fileareas_load_failed', apiLocalizedText('errors.admin.hub_nodes.fileareas_load_failed', 'Failed to load file area subscriptions'), 500); + } + }); + + SimpleRouter::put('/hub-nodes/{id}/fileareas', function($id) { + $auth = new Auth(); + $user = $auth->requireAuth(); + + $adminController = new AdminController(); + $adminController->requireAdmin($user); + + header('Content-Type: application/json'); + + try { + $payload = json_decode(file_get_contents('php://input'), true); + $fileAreaIds = is_array($payload) && isset($payload['file_area_ids']) && is_array($payload['file_area_ids']) + ? array_map('intval', $payload['file_area_ids']) + : []; + $manager = new \BinktermPHP\Hub\HubNodeManager(); + $manager->bulkSetFileAreaSubscriptions((int)$id, $fileAreaIds); + echo json_encode(['success' => true, 'fileareas' => $manager->getFileAreaSubscriptions((int)$id), 'message_code' => 'ui.admin.hub_nodes.fileareas_saved']); + } catch (Throwable $e) { + apiError('errors.admin.hub_nodes.fileareas_save_failed', $e->getMessage(), 400); + } + }); + + SimpleRouter::get('/hub-nodes/next-point', function() { + $auth = new Auth(); + $user = $auth->requireAuth(); + + $adminController = new AdminController(); + $adminController->requireAdmin($user); + + header('Content-Type: application/json'); + + try { + $bossAddress = (string)($_GET['boss_address'] ?? ''); + $manager = new \BinktermPHP\Hub\HubNodeManager(); + echo json_encode(['success' => true, 'point_number' => $manager->suggestNextPointNumber($bossAddress)]); + } catch (Throwable $e) { + apiError('errors.admin.hub_nodes.next_point_failed', apiLocalizedText('errors.admin.hub_nodes.next_point_failed', 'Failed to determine next point number'), 500); + } + }); + SimpleRouter::get('/binkp-config', function() { $auth = new Auth(); $user = $auth->requireAuth(); diff --git a/routes/api-routes.php b/routes/api-routes.php index 5f0b6bc89..047aa13f3 100644 --- a/routes/api-routes.php +++ b/routes/api-routes.php @@ -9231,6 +9231,64 @@ function apiLocalizeErrorPayload(array $payload, ?array $user = null): array readfile($filepath); }); + SimpleRouter::get('/binkp/hub-outbound', function() { + $user = RouteHelper::requireAuth(); + requireBinkpAdmin($user); + + header('Content-Type: application/json'); + + $controller = new \BinktermPHP\Binkp\Web\BinkpController(); + echo json_encode($controller->getHubOutboundQueue()); + }); + + SimpleRouter::get('/binkp/hub-outbound/inspect', function() { + $user = RouteHelper::requireAuth(); + requireBinkpAdmin($user); + + header('Content-Type: application/json'); + + $id = (int)($_GET['id'] ?? 0); + if ($id <= 0) { + apiError('errors.binkp.hub_outbound.invalid_id', 'Invalid parameters', 400); + return; + } + + $controller = new \BinktermPHP\Binkp\Web\BinkpController(); + echo json_encode($controller->inspectHubOutboundPacket($id)); + }); + + SimpleRouter::get('/binkp/hub-outbound/download', function() { + $user = RouteHelper::requireAuth(); + requireBinkpAdmin($user); + + if (!\BinktermPHP\License::isValid()) { + header('Content-Type: application/json'); + apiError('errors.binkp.kept_packets.license_required', apiLocalizedText('errors.binkp.kept_packets.license_required', 'Viewing packets requires a registered license', $user), 403); + return; + } + + $id = (int)($_GET['id'] ?? 0); + if ($id <= 0) { + header('Content-Type: application/json'); + apiError('errors.binkp.hub_outbound.invalid_id', 'Invalid parameters', 400); + return; + } + + $controller = new \BinktermPHP\Binkp\Web\BinkpController(); + $packet = $controller->getHubOutboundPacketBytes($id); + if ($packet === null) { + header('Content-Type: application/json'); + apiError('errors.binkp.queue.inspect_failed', 'Packet not found', 404); + return; + } + + header('Content-Type: application/octet-stream'); + header('Content-Length: ' . strlen($packet['bytes'])); + header('Content-Disposition: attachment; filename="' . $packet['filename'] . '"'); + header('X-Content-Type-Options: nosniff'); + echo $packet['bytes']; + }); + SimpleRouter::get('/binkp/kept-packets/bundle/list', function() { $user = RouteHelper::requireAuth(); requireBinkpAdmin($user); diff --git a/scripts/binkp_poll.php b/scripts/binkp_poll.php index eb7901ee8..2dd37da4b 100755 --- a/scripts/binkp_poll.php +++ b/scripts/binkp_poll.php @@ -14,6 +14,7 @@ function showUsage() echo "Usage: php binkp_poll.php [options] [address]\n"; echo "Options:\n"; echo " --all Poll all configured uplinks\n"; + echo " --all-hub-nodes Poll all node-type hub nodes/downlinks with a routable host\n"; echo " --test Test connection without polling\n"; echo " --hostname=HOST Override hostname for connection\n"; echo " --port=PORT Override port for connection\n"; @@ -28,9 +29,12 @@ function showUsage() echo "\n"; echo "Examples:\n"; echo " php binkp_poll.php --all\n"; + echo " php binkp_poll.php --all-hub-nodes\n"; echo " php binkp_poll.php 1:123/456\n"; echo " php binkp_poll.php --test --hostname=bbs.example.com 1:123/456\n"; echo "\n"; + echo "A plain [address] also resolves hub nodes/points automatically (via their\n"; + echo "configured inet_host/port/session_password) if it isn't a configured uplink.\n"; } function parseArgs($argv) @@ -153,7 +157,30 @@ function logResult($result, $logger, $address = null) } exit($successCount === $totalCount ? 0 : 1); - + + } elseif (isset($args['all-hub-nodes'])) { + if (!$quiet) $logger->log('INFO', 'Polling all node-type hub nodes with a routable host...'); + + $results = $client->pollAllHubNodes(); + + foreach ($results as $address => $result) { + $routeFreqResponses($result); + if ($quiet) { + echo "{$address}: " . formatResult($result, true) . "\n"; + } else { + logResult($result, $logger, $address); + } + } + + $successCount = count(array_filter($results, function($r) { return $r['success']; })); + $totalCount = count($results); + + if (!$quiet) { + $logger->log('INFO', "Summary: {$successCount}/{$totalCount} successful"); + } + + exit($successCount === $totalCount ? 0 : 1); + } elseif (!empty($positional)) { $address = $positional[0]; diff --git a/scripts/binkp_server.php b/scripts/binkp_server.php index 623aaccd7..e3ecc9419 100755 --- a/scripts/binkp_server.php +++ b/scripts/binkp_server.php @@ -4,6 +4,7 @@ chdir(__DIR__."/../"); require_once __DIR__ . '/../vendor/autoload.php'; +require_once __DIR__ . '/../src/functions.php'; use BinktermPHP\Binkp\Protocol\BinkpServer; use BinktermPHP\Binkp\Config\BinkpConfig; diff --git a/scripts/binkp_test_client.php b/scripts/binkp_test_client.php index 4f2a537cb..54a16a9d1 100755 --- a/scripts/binkp_test_client.php +++ b/scripts/binkp_test_client.php @@ -1,140 +1,145 @@ #!/usr/bin/env php 'M_NUL', - M_ADR => 'M_ADR', - M_PWD => 'M_PWD', - M_FILE => 'M_FILE', - M_OK => 'M_OK', - M_EOB => 'M_EOB', - M_GOT => 'M_GOT', - M_ERR => 'M_ERR', - M_BSY => 'M_BSY', - M_GET => 'M_GET', - M_SKIP => 'M_SKIP', -]; +require_once __DIR__ . '/../vendor/autoload.php'; +require_once __DIR__ . '/../src/functions.php'; -function showUsage() { - echo "Binkp Test Client - Standalone binkp connection tester\n"; - echo "======================================================\n\n"; +use BinktermPHP\Binkp\Protocol\BinkpFrame; +use BinktermPHP\Binkp\Protocol\PacketInspector; +use BinktermPHP\BinkdProcessor; + +function showUsage() +{ + echo "Binkp Test Client - test connections to BinktermPHP as a point/downlink\n"; + echo "=========================================================================\n\n"; echo "Usage: php binkp_test_client.php [options]\n\n"; - echo "Options:\n"; - echo " --host=HOST Remote host to connect to (required)\n"; - echo " --port=PORT Remote port (default: 24554)\n"; - echo " --address=ADDR Our FTN address (default: 1:999/999)\n"; - echo " --password=PWD Session password (default: empty)\n"; - echo " --sysname=NAME Our system name (default: Test System)\n"; - echo " --sysop=NAME Our sysop name (default: Test Sysop)\n"; - echo " --location=LOC Our location (default: Test Location)\n"; - echo " --timeout=SEC Connection timeout (default: 30)\n"; - echo " --send-file=PATH Send a file to the remote system\n"; - echo " --verbose Show detailed frame data\n"; - echo " --help Show this help message\n\n"; + echo "Connection:\n"; + echo " --host=HOST Remote host to connect to (required)\n"; + echo " --port=PORT Remote port (default: 24554)\n"; + echo " --address=ADDR Our (test point's) FTN address (default: 1:999/999)\n"; + echo " --password=PWD Session password (default: empty)\n"; + echo " --sysname=NAME Our system name (default: Test System)\n"; + echo " --sysop=NAME Our sysop name (default: Test Sysop)\n"; + echo " --location=LOC Our location (default: Test Location)\n"; + echo " --timeout=SEC Connection/session timeout (default: 30)\n"; + echo " --no-cram Force plaintext password even if CRAM-MD5 is offered\n"; + echo " --verbose Show detailed frame data\n\n"; + echo "Sending:\n"; + echo " --send-file=PATH Send an existing file (e.g. a .pkt) as-is\n"; + echo " --compose-netmail Build and send a one-message test netmail packet\n"; + echo " --to=ADDR Destination/boss address (required with --compose-netmail\n"; + echo " and --compose-echomail)\n"; + echo " --to-name=NAME Netmail To: name (default: sysop; ignored for echomail, always All)\n"; + echo " --subject=TEXT Message subject (default: Test message)\n"; + echo " --body=TEXT Message body text\n"; + echo " --body-file=PATH Read message body from a file instead of --body\n"; + echo " --compose-echomail Build and send a one-message test echomail packet\n"; + echo " --area=TAG Echoarea tag (required with --compose-echomail)\n"; + echo " --domain=NAME Echoarea network domain, for MSGID (optional)\n\n"; + echo "Receiving:\n"; + echo " --save-dir=PATH Where to save received files (default: data/binkp_test_client/)\n"; + echo " --no-dump Don't print packet header/message dump for received .pkt files\n\n"; + echo " --help Show this help message\n\n"; echo "Examples:\n"; - echo " php binkp_test_client.php --host=bbs.example.com --address=1:123/456\n"; - echo " php binkp_test_client.php --host=localhost --port=24554 --password=secret\n"; - echo " php binkp_test_client.php --host=bbs.example.com --send-file=packet.pkt\n\n"; + echo " php binkp_test_client.php --host=localhost --address=1:153/149.1 --password=secret\n"; + echo " php binkp_test_client.php --host=localhost --address=1:153/149.1 --password=secret \\\n"; + echo " --compose-netmail --to=1:1/1 --subject=\"Hi\" --body=\"Test from a point\"\n"; + echo " php binkp_test_client.php --host=localhost --address=1:153/149.1 --password=secret \\\n"; + echo " --compose-echomail --to=1:1/1 --area=GENERAL --subject=\"Hi\" --body=\"Test post from a point\"\n"; + echo " php binkp_test_client.php --host=localhost --address=1:153/149.1 --password=secret --no-cram\n\n"; } -function log_msg($message, $level = 'INFO') { +function log_msg($message, $level = 'INFO') +{ $timestamp = date('H:i:s'); echo "[$timestamp] [$level] $message\n"; } -function createFrame($command, $data = '') { - $dataLen = strlen($data); - $frameLen = $dataLen + 1; // +1 for command byte - - // Set high bit to indicate command frame - $header = pack('n', $frameLen | 0x8000); - - return $header . chr($command) . $data; +/** + * Parse "OPT CRAM-MD5-" out of an M_NUL payload, if present. + */ +function parseCramChallenge(string $nulData): ?string +{ + if (preg_match('/CRAM-MD5-([0-9a-fA-F]+)/', $nulData, $m)) { + return strtolower($m[1]); + } + return null; } -function createDataFrame($data) { - $dataLen = strlen($data); - $header = pack('n', $dataLen); // No high bit for data frame - return $header . $data; +function computeCramDigest(string $challengeHex, string $password): string +{ + return hash_hmac('md5', hex2bin($challengeHex), $password); } -function readFrame($socket, $timeout = 30) { - global $commandNames; - - // Set socket timeout - stream_set_timeout($socket, $timeout); - - // Read 2-byte header - $header = fread($socket, 2); - if ($header === false || strlen($header) < 2) { - return null; +/** + * Resolve the message body from --body-file if given, else --body. + * Exits with an error if --body-file was given but doesn't exist. + */ +function resolveBody(array $options): string +{ + if (!$options['body-file']) { + return $options['body']; } - - $headerVal = unpack('n', $header)[1]; - $isCommand = ($headerVal & 0x8000) !== 0; - $length = $headerVal & 0x7FFF; - - if ($length === 0) { - return ['is_command' => $isCommand, 'command' => null, 'data' => '']; + if (!file_exists($options['body-file'])) { + echo "Error: --body-file path does not exist: {$options['body-file']}\n"; + exit(1); } + return file_get_contents($options['body-file']); +} - // Read frame data - $data = ''; - $remaining = $length; - while ($remaining > 0) { - $chunk = fread($socket, $remaining); - if ($chunk === false || strlen($chunk) === 0) { - break; - } - $data .= $chunk; - $remaining -= strlen($chunk); +/** + * Print a packet dump in the same shape as the /binkp admin queue viewer. + */ +function dumpPacket(string $filepath): void +{ + $result = PacketInspector::inspect($filepath); + if (empty($result['success'])) { + log_msg("Packet dump failed: " . ($result['error'] ?? 'unknown error'), 'WARNING'); + return; } - if ($isCommand && strlen($data) > 0) { - $command = ord($data[0]); - $commandData = substr($data, 1); - return [ - 'is_command' => true, - 'command' => $command, - 'command_name' => $commandNames[$command] ?? "UNKNOWN($command)", - 'data' => $commandData - ]; + $p = $result['packet']; + echo "\n--- Packet Header: " . basename($filepath) . " ---\n"; + echo " From: {$p['orig_address']}\n"; + echo " To: {$p['dest_address']}\n"; + echo " Date: {$p['created']}\n"; + echo " Size: {$p['file_size']} bytes\n"; + echo " Packet Version: {$p['packet_version']}\n"; + echo " Product Code: {$p['product_code']}\n"; + echo " Password: " . ($p['has_password'] ? 'yes' : 'none') . "\n"; + + $messages = $result['messages'] ?? []; + echo "\n Messages: " . count($messages) . "\n"; + foreach ($messages as $i => $m) { + $flags = empty($m['flags']) ? '' : ' [' . implode(',', $m['flags']) . ']'; + printf( + " #%d %s -> %s \"%s\" %s%s\n", + $i + 1, + $m['from'], + $m['to'], + $m['subject'], + $m['date'], + $flags + ); } - - return ['is_command' => false, 'command' => null, 'data' => $data]; -} - -function sendFrame($socket, $command, $data = '') { - global $commandNames; - $frame = createFrame($command, $data); - $written = fwrite($socket, $frame); - $cmdName = $commandNames[$command] ?? "UNKNOWN($command)"; - log_msg("SENT: $cmdName" . ($data ? " [$data]" : "")); - return $written; -} - -function sendDataFrame($socket, $data) { - $frame = createDataFrame($data); - return fwrite($socket, $frame); + echo "\n"; } // Parse command line arguments @@ -147,7 +152,19 @@ function sendDataFrame($socket, $data) { 'sysop' => 'Test Sysop', 'location' => 'Test Location', 'timeout' => 30, + 'no-cram' => false, 'send-file' => null, + 'compose-netmail' => false, + 'compose-echomail' => false, + 'to' => null, + 'to-name' => null, + 'area' => null, + 'domain' => null, + 'subject' => 'Test message', + 'body' => 'This is a test message from binkp_test_client.php.', + 'body-file' => null, + 'save-dir' => null, + 'no-dump' => false, 'verbose' => false, 'help' => false, ]; @@ -172,6 +189,9 @@ function sendDataFrame($socket, $data) { $key = $matches[1]; if (array_key_exists($key, $options)) { $options[$key] = true; + } else { + echo "Unknown option: --$key\n"; + exit(1); } } } @@ -187,6 +207,76 @@ function sendDataFrame($socket, $data) { exit(1); } +if ($options['compose-netmail'] && !$options['to']) { + echo "Error: --compose-netmail requires --to=ADDR\n\n"; + exit(1); +} + +if ($options['compose-echomail']) { + if (!$options['to']) { + echo "Error: --compose-echomail requires --to=ADDR (the boss/uplink address)\n\n"; + exit(1); + } + if (!$options['area']) { + echo "Error: --compose-echomail requires --area=TAG\n\n"; + exit(1); + } +} + +$saveDir = $options['save-dir'] ?: (__DIR__ . '/../data/binkp_test_client'); +if (!is_dir($saveDir)) { + mkdir($saveDir, 0755, true); +} + +// Build the outbound file list up front (so we can fail fast on bad input +// before even opening the socket). +$outboundFiles = []; +if ($options['send-file']) { + if (!file_exists($options['send-file'])) { + echo "Error: --send-file path does not exist: {$options['send-file']}\n"; + exit(1); + } + $outboundFiles[] = $options['send-file']; +} +if ($options['compose-netmail']) { + $tmpPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'binkp_test_client_' . uniqid('', true) . '.pkt'; + $message = [ + 'from_address' => $options['address'], + 'to_address' => $options['to'], + 'from_name' => $options['sysop'], + 'to_name' => $options['to-name'] ?: 'sysop', + 'subject' => $options['subject'], + 'message_text' => resolveBody($options), + 'date_written' => date('Y-m-d H:i:s'), + 'attributes' => 0x0001, // private/netmail + 'is_echomail' => false, + ]; + + log_msg("Composing test netmail: {$options['address']} -> {$options['to']} \"{$options['subject']}\""); + (new BinkdProcessor())->createOutboundPacket([$message], $options['to'], $tmpPath); + $outboundFiles[] = $tmpPath; +} +if ($options['compose-echomail']) { + $tmpPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'binkp_test_client_' . uniqid('', true) . '.pkt'; + $message = [ + 'from_address' => $options['address'], + 'to_address' => $options['to'], + 'from_name' => $options['sysop'], + 'to_name' => 'All', + 'subject' => $options['subject'], + 'message_text' => resolveBody($options), + 'date_written' => date('Y-m-d H:i:s'), + 'attributes' => 0x0000, // echomail (no private bit) + 'is_echomail' => true, + 'echoarea_tag' => strtoupper($options['area']), + 'echoarea_domain' => $options['domain'] ?: null, + ]; + + log_msg("Composing test echomail: {$options['address']} -> {$options['to']} area={$message['echoarea_tag']} \"{$options['subject']}\""); + (new BinkdProcessor())->createOutboundPacket([$message], $options['to'], $tmpPath); + $outboundFiles[] = $tmpPath; +} + // Main client logic log_msg("Binkp Test Client Starting"); log_msg("Connecting to {$options['host']}:{$options['port']}..."); @@ -206,150 +296,359 @@ function sendDataFrame($socket, $data) { log_msg("Connected successfully!"); stream_set_timeout($socket, $options['timeout']); -// Send our system info +function sendCmd($socket, int $command, string $data = ''): void +{ + BinkpFrame::createCommand($command, $data)->writeToSocket($socket); +} + +function sendData($socket, string $data): void +{ + BinkpFrame::createData($data)->writeToSocket($socket); +} + +// Send our system info + address log_msg("Sending system information..."); -sendFrame($socket, M_NUL, "SYS {$options['sysname']}"); -sendFrame($socket, M_NUL, "ZYZ {$options['sysop']}"); -sendFrame($socket, M_NUL, "LOC {$options['location']}"); -sendFrame($socket, M_NUL, "VER BinkpTestClient/1.0 binkp/1.0"); -sendFrame($socket, M_NUL, "TIME " . gmdate('D, d M Y H:i:s') . " UTC"); +sendCmd($socket, BinkpFrame::M_NUL, "SYS {$options['sysname']}"); +sendCmd($socket, BinkpFrame::M_NUL, "ZYZ {$options['sysop']}"); +sendCmd($socket, BinkpFrame::M_NUL, "LOC {$options['location']}"); +sendCmd($socket, BinkpFrame::M_NUL, "VER BinkpTestClient/2.0 binkp/1.1"); +sendCmd($socket, BinkpFrame::M_NUL, "TIME " . gmdate('D, d M Y H:i:s') . " UTC"); -// Send our address log_msg("Sending address: {$options['address']}"); -sendFrame($socket, M_ADR, $options['address']); - -// Read frames from server -log_msg("Waiting for server response..."); +sendCmd($socket, BinkpFrame::M_ADR, $options['address']); +// ── Handshake phase ────────────────────────────────────────────────────── +$cramChallenge = null; +$sentPassword = false; $authenticated = false; -$remoteAddress = null; -$gotRemoteAddress = false; -$sessionComplete = false; - -while (!$sessionComplete && !feof($socket)) { - $frame = readFrame($socket, $options['timeout']); +$handshakeFailed = false; +$deadline = time() + (int)$options['timeout']; + +while (!$authenticated && !$handshakeFailed && time() < $deadline) { + $frame = BinkpFrame::parseFromSocket($socket, true); + if ($frame === null) { + $diag = BinkpFrame::getLastReadDiagnostics(); + if (($diag['reason'] ?? null) === 'eof') { + log_msg("Connection closed during handshake", 'ERROR'); + $handshakeFailed = true; + break; + } + usleep(50000); + continue; + } - if (!$frame) { - log_msg("No frame received or timeout", 'WARNING'); - break; + if (!$frame->isCommand()) { + continue; } - if ($frame['is_command']) { - $cmdName = $frame['command_name']; - $data = $frame['data']; + $cmd = $frame->getCommand(); + $data = $frame->getData(); + if ($options['verbose']) { + log_msg("RECV: " . ($data !== '' ? "cmd={$cmd} [{$data}]" : "cmd={$cmd}")); + } - log_msg("RECV: $cmdName" . ($data ? " [$data]" : "")); + switch ($cmd) { + case BinkpFrame::M_NUL: + $challenge = parseCramChallenge($data); + if ($challenge !== null) { + $cramChallenge = $challenge; + log_msg("Server offers CRAM-MD5 authentication"); + } + if ($options['verbose']) { + log_msg(" System info: $data", 'DEBUG'); + } + break; - switch ($frame['command']) { - case M_NUL: - // Info frame, just log it - if ($options['verbose']) { - log_msg(" System info: $data", 'DEBUG'); + case BinkpFrame::M_ADR: + log_msg("Remote address(es): $data"); + if (!$sentPassword) { + if ($cramChallenge !== null && !$options['no-cram']) { + $digest = computeCramDigest($cramChallenge, $options['password']); + sendCmd($socket, BinkpFrame::M_PWD, "CRAM-MD5-{$digest}"); + log_msg("Sending CRAM-MD5 password response..."); + } else { + sendCmd($socket, BinkpFrame::M_PWD, $options['password']); + log_msg($cramChallenge !== null + ? "Sending plaintext password (--no-cram forced)..." + : "Sending plaintext password..."); } - break; + $sentPassword = true; + } + break; - case M_ADR: - $remoteAddress = $data; - $gotRemoteAddress = true; - log_msg("Remote address: $remoteAddress"); + case BinkpFrame::M_OK: + log_msg("Authentication successful! ($data)", 'SUCCESS'); + $authenticated = true; + break; - // Now send password - log_msg("Sending password..."); - sendFrame($socket, M_PWD, $options['password']); - break; + case BinkpFrame::M_ERR: + log_msg("Authentication failed: $data", 'ERROR'); + $handshakeFailed = true; + break; - case M_OK: - log_msg("Authentication successful!", 'SUCCESS'); - $authenticated = true; - - // If we have a file to send, do it now - if ($options['send-file'] && file_exists($options['send-file'])) { - $filePath = $options['send-file']; - $fileName = basename($filePath); - $fileSize = filesize($filePath); - $fileTime = filemtime($filePath); - - log_msg("Sending file: $fileName ($fileSize bytes)"); - - // Send M_FILE - $fileInfo = "$fileName $fileSize $fileTime 0"; - sendFrame($socket, M_FILE, $fileInfo); - - // Send file data - $handle = fopen($filePath, 'rb'); - $bytesSent = 0; - while (!feof($handle)) { - $chunk = fread($handle, 4096); - if ($chunk !== false && strlen($chunk) > 0) { - sendDataFrame($socket, $chunk); - $bytesSent += strlen($chunk); - } - } - fclose($handle); - log_msg("Sent $bytesSent bytes of file data"); + case BinkpFrame::M_BSY: + log_msg("Server busy: $data", 'ERROR'); + $handshakeFailed = true; + break; - // Wait for M_GOT - log_msg("Waiting for M_GOT..."); - } else { - // No file to send, send EOB - log_msg("No files to send, sending EOB"); - sendFrame($socket, M_EOB); + default: + log_msg("Unexpected command during handshake: $cmd", 'WARNING'); + } +} + +if (!$authenticated) { + log_msg("Handshake did not complete", 'ERROR'); + fclose($socket); + exit(1); +} + +// ── File transfer phase ────────────────────────────────────────────────── +$haveSentEob = false; +$haveReceivedEob = false; +$sentAllFiles = false; +$receivedFiles = []; +$sentFiles = []; + +$currentSendIndex = 0; +$waitingForGot = null; // filename we're waiting to hear M_GOT for + +/** @var resource|null $recvHandle */ +$recvHandle = null; +$recvMeta = null; // ['name'=>, 'size'=>, 'received'=>, 'tmp_path'=>] + +function sendNextOutboundFile($socket, array $outboundFiles, int &$index, ?string &$waitingForGot, array &$sentFiles): bool +{ + if ($index >= count($outboundFiles)) { + return false; + } + $path = $outboundFiles[$index]; + $index++; + + $filename = basename($path); + $size = filesize($path); + $mtime = filemtime($path); + + log_msg("Sending file: $filename ($size bytes)"); + sendCmd($socket, BinkpFrame::M_FILE, "$filename $size $mtime 0"); + + $handle = fopen($path, 'rb'); + $sent = 0; + while (!feof($handle)) { + $chunk = fread($handle, 4096); + if ($chunk === false || $chunk === '') { + break; + } + sendData($socket, $chunk); + $sent += strlen($chunk); + } + fclose($handle); + log_msg("Sent $sent bytes of file data for $filename"); + + $waitingForGot = $filename; + $sentFiles[] = $filename; + return true; +} + +// Kick off sending, if we have anything queued. +if (!empty($outboundFiles)) { + sendNextOutboundFile($socket, $outboundFiles, $currentSendIndex, $waitingForGot, $sentFiles); +} else { + sendCmd($socket, BinkpFrame::M_EOB); + $haveSentEob = true; + log_msg("Nothing to send, sent EOB"); +} + +$sessionTimeout = max(30, (int)$options['timeout']); +$loopStart = time(); +$lastActivity = time(); +$idleCloseGraceSeconds = 3; +$readyToCloseSince = null; +$terminated = false; +// Bound how many times we'll reply to a received EOB. Real binkd needs at +// most one extra (normally-empty) round after the first exchange before it +// closes on its own (see src/Binkp/CLAUDE.md); replying unconditionally +// forever risks an EOB ping-pong with any peer that also always-replies +// (observed in testing) since neither side ever falls silent first. +$eobRepliesSent = 0; +$maxEobReplies = 3; + +while (!$terminated) { + $hasActiveTransfer = $recvMeta !== null; + $inactivity = time() - $lastActivity; + + // Once both EOBs are done and nothing is mid-transfer, give the peer a + // few seconds to close first (matches BinkpSession's documented binkd + // interop behavior - closing first ourselves can get logged as a failed + // session on some binkd builds even though the transfer succeeded). + if ($haveSentEob && $haveReceivedEob && !$hasActiveTransfer && $waitingForGot === null) { + if ($readyToCloseSince === null) { + log_msg("EOB exchange complete, waiting up to {$idleCloseGraceSeconds}s for peer to close first", 'DEBUG'); + $readyToCloseSince = time(); + } elseif (time() - $readyToCloseSince >= $idleCloseGraceSeconds) { + log_msg("Peer did not close, closing ourselves"); + break; + } + } else { + $readyToCloseSince = null; + } + + if (!$hasActiveTransfer && (time() - $loopStart) >= $sessionTimeout && $readyToCloseSince === null) { + log_msg("Session timeout waiting for EOB exchange to complete", 'WARNING'); + break; + } + if ($inactivity >= $sessionTimeout) { + log_msg("No activity for {$inactivity}s, closing", 'WARNING'); + break; + } + + $frame = BinkpFrame::parseFromSocket($socket, true); + if ($frame === null) { + $diag = BinkpFrame::getLastReadDiagnostics(); + if (($diag['reason'] ?? null) === 'eof') { + if ($haveSentEob && $haveReceivedEob) { + log_msg("Peer closed the connection after EOB exchange - session complete"); + } else { + log_msg("Peer closed the connection before EOB exchange completed", 'WARNING'); + } + break; + } + usleep(100000); + continue; + } + + $lastActivity = time(); + // Don't let a redundant EOB we've already stopped replying to (see + // $maxEobReplies above) keep restarting the close-grace countdown - + // otherwise a looping peer would keep us open indefinitely even though + // we've deliberately gone silent on our end. + $isIgnoredEobPing = $frame->isCommand() + && $frame->getCommand() === BinkpFrame::M_EOB + && $eobRepliesSent >= $maxEobReplies; + if (!$isIgnoredEobPing) { + $readyToCloseSince = null; + } + + if ($frame->isCommand()) { + $cmd = $frame->getCommand(); + $data = $frame->getData(); + if ($options['verbose']) { + log_msg("RECV: cmd={$cmd}" . ($data !== '' ? " [{$data}]" : "")); + } + + switch ($cmd) { + case BinkpFrame::M_FILE: + // Server is pushing us a file. + $parts = explode(' ', $data, 4); + $recvName = basename($parts[0] ?? 'unknown.pkt'); + $recvSize = isset($parts[1]) ? (int)$parts[1] : 0; + $recvTime = isset($parts[2]) ? (int)$parts[2] : time(); + + log_msg("Server sending file: $recvName ($recvSize bytes)"); + $tmpPath = $saveDir . DIRECTORY_SEPARATOR . $recvName . '.tmp'; + $recvHandle = fopen($tmpPath, 'wb'); + $recvMeta = ['name' => $recvName, 'size' => $recvSize, 'time' => $recvTime, 'received' => 0, 'tmp_path' => $tmpPath]; + + if ($recvSize === 0) { + // Zero-byte file - nothing more to receive, confirm immediately. + fclose($recvHandle); + finishReceivedFile($recvMeta, $saveDir, $socket, $options, $receivedFiles); + $recvHandle = null; + $recvMeta = null; } break; - case M_GOT: - log_msg("File received confirmation: $data"); - // Send EOB after file is confirmed - sendFrame($socket, M_EOB); + case BinkpFrame::M_GOT: + log_msg("File confirmed by peer: $data"); + $waitingForGot = null; + // Send the next queued file, if any; otherwise we're done sending. + if (!sendNextOutboundFile($socket, $outboundFiles, $currentSendIndex, $waitingForGot, $sentFiles)) { + if (!$haveSentEob) { + sendCmd($socket, BinkpFrame::M_EOB); + $haveSentEob = true; + log_msg("All files sent, sent EOB"); + } + } break; - case M_EOB: - log_msg("End of batch received"); - if ($authenticated) { - log_msg("Session complete"); - $sessionComplete = true; + case BinkpFrame::M_EOB: + log_msg("Received EOB"); + $haveReceivedEob = true; + if ($eobRepliesSent < $maxEobReplies) { + sendCmd($socket, BinkpFrame::M_EOB); + $eobRepliesSent++; + if (!$haveSentEob) { + $haveSentEob = true; + } + } else { + log_msg("Already replied to EOB {$maxEobReplies} time(s), not replying again (peer appears to be looping)", 'WARNING'); } break; - case M_ERR: + case BinkpFrame::M_ERR: log_msg("Error from server: $data", 'ERROR'); - $sessionComplete = true; + $terminated = true; break; - case M_BSY: + case BinkpFrame::M_BSY: log_msg("Server busy: $data", 'WARNING'); - $sessionComplete = true; + $terminated = true; break; - case M_FILE: - // Server wants to send us a file - log_msg("Server sending file: $data"); - // Parse file info: name size time offset - $parts = explode(' ', $data); - if (count($parts) >= 3) { - $fileName = $parts[0]; - $fileSize = (int)$parts[1]; - log_msg("Receiving file $fileName ($fileSize bytes)..."); - - // For testing, we'll skip the file - sendFrame($socket, M_SKIP, $data); - log_msg("Skipped file (test mode)"); + case BinkpFrame::M_NUL: + if ($options['verbose']) { + log_msg("M_NUL during transfer: $data", 'DEBUG'); } break; default: - log_msg("Unhandled command: $cmdName", 'WARNING'); + log_msg("Unhandled command: $cmd", 'WARNING'); } } else { - // Data frame - $dataLen = strlen($frame['data']); - log_msg("RECV: DATA [$dataLen bytes]"); + // Data frame - part of a file we're receiving. + $chunk = $frame->getData(); + if ($recvHandle !== null && $recvMeta !== null) { + fwrite($recvHandle, $chunk); + $recvMeta['received'] += strlen($chunk); + + if ($recvMeta['received'] >= $recvMeta['size']) { + fclose($recvHandle); + finishReceivedFile($recvMeta, $saveDir, $socket, $options, $receivedFiles); + $recvHandle = null; + $recvMeta = null; + } + } else { + log_msg("Received unexpected data frame (" . strlen($chunk) . " bytes) with no active file", 'WARNING'); + } } } +if ($recvHandle !== null) { + fclose($recvHandle); +} + fclose($socket); log_msg("Connection closed"); -log_msg("Session " . ($authenticated ? "succeeded" : "failed")); +log_msg("Files sent: " . count($sentFiles) . (empty($sentFiles) ? '' : ' (' . implode(', ', $sentFiles) . ')')); +log_msg("Files received: " . count($receivedFiles) . (empty($receivedFiles) ? '' : ' (' . implode(', ', $receivedFiles) . ')')); +log_msg("Session " . (($haveSentEob && $haveReceivedEob) ? "succeeded" : "did not complete cleanly")); -exit($authenticated ? 0 : 1); +exit(($haveSentEob && $haveReceivedEob) ? 0 : 1); + +/** + * Finalize a fully-received file: rename from .tmp, send M_GOT, and dump + * .pkt contents unless --no-dump was given. + */ +function finishReceivedFile(array $meta, string $saveDir, $socket, array $options, array &$receivedFiles): void +{ + $finalPath = $saveDir . DIRECTORY_SEPARATOR . $meta['name']; + rename($meta['tmp_path'], $finalPath); + + log_msg("Received file: {$meta['name']} ({$meta['received']} bytes) -> $finalPath"); + sendCmd($socket, BinkpFrame::M_GOT, "{$meta['name']} {$meta['size']} {$meta['time']}"); + $receivedFiles[] = $meta['name']; + + if (!$options['no-dump'] && preg_match('/\.pkt$/i', $meta['name'])) { + dumpPacket($finalPath); + } +} diff --git a/scripts/database_maintenance.php b/scripts/database_maintenance.php index 563b37f3f..69d1cfd5e 100755 --- a/scripts/database_maintenance.php +++ b/scripts/database_maintenance.php @@ -330,10 +330,51 @@ } // ======================================================================== - // 10. PostgreSQL VACUUM and ANALYZE (if not dry run) + // 10. Clean up old hub_node_outbound packets (sent/failed only, per node + // queue_retention_days - pending/held rows are never purged here) + // ======================================================================== + echo "\n[10] Cleaning old hub node outbound packets...\n"; + + $tableCheck = $db->query(" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'hub_node_outbound' + ) + "); + + if ($tableCheck->fetchColumn()) { + if ($dryRun) { + $stmt = $db->query(" + SELECT COUNT(*) AS count + FROM hub_node_outbound hno + JOIN hub_nodes hn ON hn.id = hno.hub_node_id + WHERE hno.status IN ('sent', 'failed') + AND COALESCE(hno.sent_at, hno.created_at) < NOW() - (hn.queue_retention_days || ' days')::interval + "); + $result = $stmt->fetch(); + echo " Would delete {$result['count']} sent/failed hub node outbound packets past their node's retention period\n"; + } else { + $stmt = $db->prepare(" + DELETE FROM hub_node_outbound hno + USING hub_nodes hn + WHERE hn.id = hno.hub_node_id + AND hno.status IN ('sent', 'failed') + AND COALESCE(hno.sent_at, hno.created_at) < NOW() - (hn.queue_retention_days || ' days')::interval + "); + $stmt->execute(); + $deleted = $stmt->rowCount(); + $totalCleaned += $deleted; + echo " Deleted $deleted sent/failed hub node outbound packets past their node's retention period\n"; + } + } else { + echo " Table 'hub_node_outbound' does not exist, skipping\n"; + } + + // ======================================================================== + // 11. PostgreSQL VACUUM and ANALYZE (if not dry run) // ======================================================================== if (!$dryRun) { - echo "\n[10] Running VACUUM and ANALYZE...\n"; + echo "\n[11] Running VACUUM and ANALYZE...\n"; $tables = $db->query(" SELECT relname AS table_name @@ -351,7 +392,7 @@ } } } else { - echo "\n[10] Skipping VACUUM and ANALYZE (dry run)\n"; + echo "\n[11] Skipping VACUUM and ANALYZE (dry run)\n"; } // ======================================================================== diff --git a/scripts/process_packets.php b/scripts/process_packets.php index dc33d66fa..e2e389d32 100755 --- a/scripts/process_packets.php +++ b/scripts/process_packets.php @@ -12,6 +12,9 @@ use BinktermPHP\Binkp\Logger; use BinktermPHP\Config; use BinktermPHP\Database; +use BinktermPHP\FileAreaManager; +use BinktermPHP\Hub\HubFanout; +use BinktermPHP\TicFileGenerator; use BinktermPHP\TicFileProcessor; // Initialize database @@ -72,6 +75,31 @@ function processInboundTicFiles(TicFileProcessor $ticProcessor, Logger $logger): if (isset($result['duplicate']) && $result['duplicate']) { $logger->warning(" WARN Duplicate file (skipped)"); + } else { + // Fan out to subscribed hub node downlinks/points, and relay + // onward to the file area's configured uplink(s), unless it + // came from that uplink or is already in its Seenby trail + // (docs/proposals/HubPointSystemJuly2026.md Phase 4). + try { + (new HubFanout())->fanoutFile((int)$result['file_id']); + } catch (\Throwable $e) { + $logger->error("Failed to fan out inbound TIC file to hub node subscribers: " . $e->getMessage()); + } + + try { + $fileAreaManager = new FileAreaManager(); + $fileRecord = $fileAreaManager->getFileById((int)$result['file_id']); + $fileAreaRecord = $fileRecord ? $fileAreaManager->getFileAreaById((int)$fileRecord['file_area_id']) : null; + if ($fileRecord && $fileAreaRecord) { + $ticGenerator = new TicFileGenerator(); + $createdTics = $ticGenerator->createTicFilesForUplinks($fileRecord, $fileAreaRecord); + if (count($createdTics) > 0) { + $logger->info(" Relayed inbound TIC file to " . count($createdTics) . " uplink(s)"); + } + } + } catch (\Throwable $e) { + $logger->error("Failed to relay inbound TIC file to uplinks: " . $e->getMessage()); + } } // Clean up TIC file (data file was cleaned up by processor) diff --git a/src/BinkdProcessor.php b/src/BinkdProcessor.php index b5b4a3aa1..42a50f843 100644 --- a/src/BinkdProcessor.php +++ b/src/BinkdProcessor.php @@ -951,6 +951,22 @@ private function hasAreaKludgeLine($messageText) private function storeNetmail($message, $packetInfo = null, $isInsecureSession = false, bool &$undeliverable = false) { + // Intercept Areafix/Filefix robot netmail (To: "AreaFix"/"FileFix" at + // one of our own AKAs, from a registered hub node/point). Must run + // before both the hub-node routing and FREQ intercept below - this is + // mail addressed to us to be processed as a command, not delivered + // or routed anywhere. + if ((new \BinktermPHP\Hub\HubAreafixProcessor())->processIncoming($message)) { + return; + } + + // Route transit netmail addressed to a registered hub node/point, + // if enabled. Must run before the FREQ intercept below — a FREQ + // addressed to a hub node isn't a FREQ for us to intercept. + if ((new \BinktermPHP\Hub\HubNetmailRouter())->routeIfHubNode($message)) { + return; + } + // Intercept inbound netmail FREQs (FILE_REQUEST attribute 0x0800). // These are protocol requests, not user mail — log and discard rather than deliver. if (($message['attributes'] ?? 0) & 0x0800) { @@ -961,6 +977,13 @@ private function storeNetmail($message, $packetInfo = null, $isInsecureSession = // Find target user using hybrid matching approach $userId = $this->findTargetUser($message['destAddr'], $message['toName']); + // Before giving up as undeliverable, check whether this came from one + // of our own registered hub nodes/points — if so, it's the point + // using us as its boss to relay mail onward, not misaddressed mail. + if ($userId === null && (new \BinktermPHP\Hub\HubNetmailRouter())->relayIfFromHubNode($message)) { + return; + } + // Drop undeliverable netmail — no user matched by address or name. // The old sysop catch-all has been removed to prevent misrouted echomail // (which typically has no AREA:/SEEN-BY/PATH markers and an unrecognised @@ -1531,11 +1554,57 @@ private function storeEchomail($message, $packetInfo = null, $domain) mb_substr($message['fromName'] ?? '', 0, 100), $echoarea['id'], ]); + + try { + (new \BinktermPHP\Hub\HubFanout())->fanout($newId); + } catch (\Throwable $e) { + $this->log("[BINKD] Hub fanout failed for echomail #{$newId}: " . $e->getMessage()); + } + + try { + $this->relayEchomailToUplinkIfNeeded($newId, $echoareaTag, $domain, $message['origAddr'], $bottomKludgeText); + } catch (\Throwable $e) { + $this->log("[BINKD] Uplink relay failed for echomail #{$newId}: " . $e->getMessage()); + } } //$this->log("[BINKD] Stored echomail in echoarea id ".$echoarea['id']." from=".$fromAddress." messageId=".$messageId." subject=".$message['subject']); } + /** + * Relay a newly-stored inbound echomail message to the echoarea's + * configured uplink, unless the uplink already has it (we received it + * from that same uplink, or its address is already in the message's + * SEEN-BY). Without this, echomail posted by a registered downlink/point + * would only ever be distributed to other downlinks, never forwarded up + * to the real network - only half of real FTN hub relay behavior. + */ + private function relayEchomailToUplinkIfNeeded(int $messageId, string $echoareaTag, string $domain, string $origAddr, string $bottomKludgeText): void + { + $messageHandler = new \BinktermPHP\MessageHandler(); + $uplinkAddress = $messageHandler->getEchoareaUplink($echoareaTag, $domain); + if (!$uplinkAddress) { + return; + } + + $origParts = \BinktermPHP\Echomail\EchomailSeenBy::parseFtnAddressParts(trim($origAddr)); + $uplinkParts = \BinktermPHP\Echomail\EchomailSeenBy::parseFtnAddressParts($uplinkAddress); + if ($origAddr !== '' && $origParts['net'] === $uplinkParts['net'] && $origParts['node'] === $uplinkParts['node']) { + // Received directly from this uplink - don't send it straight back. + return; + } + + $rawSeenBy = \BinktermPHP\Echomail\EchomailSeenBy::parseSeenBy($bottomKludgeText); + if (\BinktermPHP\Echomail\EchomailSeenBy::seenByContains($rawSeenBy, $uplinkAddress)) { + // Uplink already has a copy via some other path. + return; + } + + if ($messageHandler->spoolOutboundEchomail($messageId, $echoareaTag, $domain)) { + $messageHandler->flushImmediateOutboundPolls(); + } + } + private function getOrCreateEchoarea($tag,$domain) { $tag = strtoupper($tag); @@ -2035,8 +2104,14 @@ private function writeMessage($handle, $message) } elseif ($isEchomail) { // Use stored kludges from database if available if (!empty($message['kludge_lines'])) { - // Convert stored kludges to packet format (bare CR per FTS-0001) - $storedKludges = str_replace(["\r\n", "\n"], "\r", $message['kludge_lines']); + // Convert stored kludges to packet format (bare CR per FTS-0001). + // Stored kludges include the original AREA: line (kept for + // display/history), but a fresh AREA: line is always built + // separately below from echoarea_tag - drop the stored one + // here so it isn't written into the packet twice. + $storedLines = preg_split('/\r\n|\r|\n/', $message['kludge_lines']) ?: []; + $storedLines = array_filter($storedLines, static fn(string $line): bool => stripos($line, 'AREA:') !== 0); + $storedKludges = implode("\r", $storedLines); $kludgeLines .= $storedKludges . "\r"; } else { // Fallback to generating kludges if not stored (for backward compatibility) @@ -2059,38 +2134,53 @@ private function writeMessage($handle, $message) } } - $kludgeLines .= "\x01PID: BinktermPHP " . Version::getVersion() . " " . PHP_OS_FAMILY . "\r"; + // Relay/transit messages (netmail passed through from another system + // via HubNetmailRouter) already carry their true originator's PID and + // tearline embedded in the preserved kludge_lines/message_text - + // adding our own on top would duplicate both. skip_default_pid_tearline + // suppresses that for those callers only; default (unset) behavior for + // every other caller — genuinely new/local messages — is unchanged. + $skipPidTearline = !empty($message['skip_default_pid_tearline']); + + if (!$skipPidTearline) { + $kludgeLines .= "\x01PID: BinktermPHP " . Version::getVersion() . " " . PHP_OS_FAMILY . "\r"; + } // For echomail, add AREA control field first (plain text, no ^A prefix) $areaLine = ''; if ($isEchomail && isset($message['echoarea_tag'])) { $areaLine = "AREA:{$message['echoarea_tag']}\r"; // No Space after AREA } - + $messageText = $areaLine . $kludgeLines . $messageText; - + // Add tearline and origin if (!empty($messageText) && !str_ends_with($messageText, "\r")) { $messageText .= "\r"; } - $messageText .= "\r"; - $messageText .= Version::getTearlineWithComponent($message['tearline_component'] ?? null) . "\r"; - // Origin line should show the actual system address (including point if it's a point system) $systemAddress = $fromAddress; // Use the full system address including point - // Origin line is echomail-only per FTS-0004 - if ($isEchomail) { - $originText = " * Origin: "; - $origin = $this->config->getSystemOrigin(); - if (!empty($origin)) { - $originText .= $origin; - } else { - $originText .= $this->config->getSystemName(); - } + if (!$skipPidTearline) { + $messageText .= "\r"; + $messageText .= Version::getTearlineWithComponent($message['tearline_component'] ?? null) . "\r"; + + // Origin line is echomail-only per FTS-0004. Skipped along with the + // tearline above for relay/transit messages whose message_text + // already carries the true originator's tearline+origin - adding + // ours on top would duplicate both. + if ($isEchomail) { + $originText = " * Origin: "; + $origin = $this->config->getSystemOrigin(); + if (!empty($origin)) { + $originText .= $origin; + } else { + $originText .= $this->config->getSystemName(); + } - $originText .= " (" . $systemAddress . ")"; + $originText .= " (" . $systemAddress . ")"; - $messageText .= $originText; + $messageText .= $originText; + } } // Add bottom kludges (Via lines, etc.) after origin per FTS-4009.001 @@ -2101,8 +2191,12 @@ private function writeMessage($handle, $message) $messageText .= $bottomKludges; } - // Add echomail-specific control lines after bottom kludges - if ($isEchomail) { + // Add echomail-specific control lines after bottom kludges. Callers that + // already provide a fully-formed SEEN-BY/PATH set in bottom_kludges (e.g. + // BinktermPHP\Hub\HubFanout, which merges accumulated SEEN-BY/PATH across + // hops) set skip_default_seenby_path to avoid this single-hop synthesis + // duplicating what they already wrote. + if ($isEchomail && empty($message['skip_default_seenby_path'])) { $messageText .= "\r"; // Parse system address for SEEN-BY and PATH lines @@ -2206,12 +2300,23 @@ private function findTargetUser($destAddr, $toName) } } + // Strategies 3/4 below are name-based fallbacks for mail that's genuinely + // ours but whose address didn't cleanly resolve above - not a way to claim + // mail that's address-wise for a different system. Without this guard, a + // registered point relaying transit netmail through us to a third system, + // using a generic To: name like "sysop", would get misdelivered into our + // own local sysop's inbox instead of relayed onward (destAddr here is + // clearly not ours, so name matching must not override that). + if (!$this->isOwnAddress($destAddr)) { + return null; + } + // Strategy 3: Special case for 'sysop' - lookup from binkd.config if (!empty($toName) && strtolower($toName) === 'sysop') { $sysopName = $this->config->getSystemSysop(); if (!empty($sysopName)) { $stmt = $this->db->prepare(" - SELECT id FROM users + SELECT id FROM users WHERE LOWER(real_name) = LOWER(?) OR LOWER(username) = LOWER(?) LIMIT 1 "); @@ -2222,11 +2327,11 @@ private function findTargetUser($destAddr, $toName) } } } - + // Strategy 4: Name-based matching (case-insensitive) if (!empty($toName)) { $stmt = $this->db->prepare(" - SELECT id FROM users + SELECT id FROM users WHERE LOWER(real_name) = LOWER(?) OR LOWER(username) = LOWER(?) LIMIT 1 "); @@ -2236,11 +2341,37 @@ private function findTargetUser($destAddr, $toName) return $user['id']; } } - + // No match found — return null so caller can decide how to handle undeliverable mail return null; } + /** + * True if $addr is empty (treated as "could be ours" - preserves prior + * behavior for legacy/malformed packets with no usable destination) or + * matches one of our own configured AKAs (system address or an uplink's + * "me" address), comparing at the host (net/node) level so a point + * suffix on either side doesn't prevent the match. + */ + private function isOwnAddress(string $addr): bool + { + $addr = trim($addr); + if ($addr === '') { + return true; + } + + $hostAddr = strpos($addr, '.') !== false ? explode('.', $addr, 2)[0] : $addr; + + foreach ((new \BinktermPHP\Hub\HubNodeManager())->getConfiguredAkas() as $aka) { + $akaHost = strpos($aka, '.') !== false ? explode('.', $aka, 2)[0] : $aka; + if ($addr === $aka || $hostAddr === $akaHost) { + return true; + } + } + + return false; + } + private function processBundle($bundleFile) { $extension = strtolower(pathinfo($bundleFile, PATHINFO_EXTENSION)); diff --git a/src/Binkp/Connection/Scheduler.php b/src/Binkp/Connection/Scheduler.php index 315d01dcb..683af805b 100644 --- a/src/Binkp/Connection/Scheduler.php +++ b/src/Binkp/Connection/Scheduler.php @@ -24,6 +24,13 @@ class Scheduler { + /** + * Must match BinkpSession::HUB_OUTBOUND_MAX_ATTEMPTS - the two caps + * gate the same hub_node_outbound.attempts value from different sides + * (deciding whether to dial out vs. deciding what to send once connected). + */ + private const HUB_OUTBOUND_MAX_ATTEMPTS = 10; + /** @var array Unix timestamps of last outbound-triggered polls by uplink */ private $lastOutboundPollTimes; /** @var array Whether an uplink had outbound work on the previous scan */ @@ -47,6 +54,10 @@ class Scheduler private $iterationPolledAddresses = []; /** Minimum seconds between scheduled crashmail polls */ const CRASHMAIL_POLL_INTERVAL = 300; + /** Minimum seconds between scheduled hub node push checks */ + const HUB_PUSH_POLL_INTERVAL = 300; + /** Unix timestamp of last scheduled hub node push check */ + private $lastHubPushPoll = 0; public function __construct($config = null, $logger = null) { @@ -520,6 +531,7 @@ public function runDaemon($interval = 60) $this->processInboundIfNeeded(); $this->runScheduledCrashmailPoll(); + $this->runScheduledHubNodePush(); $this->processAdvertisingCampaigns(); } catch (\Exception $e) { @@ -574,6 +586,62 @@ private function runScheduledCrashmailPoll(): void } } + /** + * Push pending hub_node_outbound packets to any enabled, non-held, + * push-eligible (allow_outbound, inet_host set) node-type hub node that + * has pending work, gated by HUB_PUSH_POLL_INTERVAL. Points are pull-only + * (no inet_host) and are naturally excluded. + */ + private function runScheduledHubNodePush(): void + { + $now = time(); + $elapsed = $now - $this->lastHubPushPoll; + if ($elapsed < self::HUB_PUSH_POLL_INTERVAL) { + $remaining = self::HUB_PUSH_POLL_INTERVAL - $elapsed; + $this->log("Hub node push check not due yet ({$remaining}s remaining)", 'DEBUG'); + return; + } + + try { + $db = Database::getInstance()->getPdo(); + // Failed rows are retried up to the same attempts cap as + // BinkpSession::HUB_OUTBOUND_MAX_ATTEMPTS so a node whose only + // work is a failed (e.g. interrupted) send still gets redialed, + // not just nodes with fresh 'pending' rows. + $stmt = $db->prepare(" + SELECT DISTINCT hn.node_address + FROM hub_nodes hn + JOIN hub_node_outbound hno ON hno.hub_node_id = hn.id + WHERE (hno.status = 'pending' OR (hno.status = 'failed' AND hno.attempts < ?)) + AND hn.node_type = 'node' + AND hn.enabled = TRUE + AND hn.allow_outbound = TRUE + AND hn.hold_mail = FALSE + AND hn.inet_host IS NOT NULL + "); + $stmt->execute([self::HUB_OUTBOUND_MAX_ATTEMPTS]); + $addresses = $stmt->fetchAll(\PDO::FETCH_COLUMN) ?: []; + + if (empty($addresses)) { + $this->log("No push-eligible hub nodes with pending outbound work", 'DEBUG'); + } else { + foreach ($addresses as $address) { + $this->log("Hub node push starting for {$address}"); + $result = $this->client->binkPoll($address); + if (($result['exit_code'] ?? 1) === 0) { + $this->log("Hub node push completed for {$address}"); + } else { + $this->log("Hub node push failed for {$address}", 'ERROR'); + } + } + } + + $this->lastHubPushPoll = $now; + } catch (\Throwable $e) { + $this->log("Hub node push check error: " . $e->getMessage(), 'ERROR'); + } + } + private function processInboundIfNeeded(): void { $inboundPath = $this->config->getInboundPath(); diff --git a/src/Binkp/Protocol/BinkpClient.php b/src/Binkp/Protocol/BinkpClient.php index e95b3ec24..95ca0aecc 100644 --- a/src/Binkp/Protocol/BinkpClient.php +++ b/src/Binkp/Protocol/BinkpClient.php @@ -77,17 +77,25 @@ public function connect($address, $hostname = null, $port = null, $password = nu $uplink = $this->config->getUplinkByAddress($address); if (!$uplink && !$hostname) { - // Not a configured uplink — resolve via nodelist or binkp_zone DNS - $resolved = $this->resolveNodeHostname($address); - if ($resolved === null) { - throw new \Exception( - "Cannot resolve hostname for {$address}: not a configured uplink and not found in nodelist or binkp_zone DNS" - ); + // Not a configured uplink — check for a hub node/point with a routable host + $hubNode = (new \BinktermPHP\Hub\HubNodeManager())->getByAddress($address); + if ($hubNode && !empty($hubNode['inet_host'])) { + $hostname = $hubNode['inet_host']; + $port = $hubNode['port'] ?: 24554; + $password = $hubNode['session_password'] ?? ''; + } else { + // Not a hub node either — resolve via nodelist or binkp_zone DNS + $resolved = $this->resolveNodeHostname($address); + if ($resolved === null) { + throw new \Exception( + "Cannot resolve hostname for {$address}: not a configured uplink, not a hub node with a routable host, and not found in nodelist or binkp_zone DNS" + ); + } + $hostname = $resolved['hostname']; + $port = $resolved['port']; + // Anonymous session — no password + $password = ''; } - $hostname = $resolved['hostname']; - $port = $resolved['port']; - // Anonymous session — no password - $password = ''; } $hostname = $hostname ?: $uplink['hostname']; @@ -344,7 +352,44 @@ public function pollAllUplinks($queued_only=false) return $results; } - + + /** + * Poll every node-type hub node with a routable host (enabled, allowed to + * be pushed to, not held). Points typically have no inet_host and are + * pull-only, so they're naturally excluded. + * + * @return array + */ + public function pollAllHubNodes(): array + { + $manager = new \BinktermPHP\Hub\HubNodeManager(); + $nodes = $manager->getAll(\BinktermPHP\Hub\HubNodeManager::TYPE_NODE); + $results = []; + + foreach ($nodes as $node) { + if (!$node['enabled'] || !$node['allow_outbound'] || $node['hold_mail'] || empty($node['inet_host'])) { + continue; + } + + $address = $node['node_address']; + try { + $this->log("Polling hub node: {$address}"); + $result = $this->connect($address, $node['inet_host'], $node['port'] ?: 24554, $node['session_password'] ?? ''); + $results[$address] = $result; + $this->log("Successfully polled hub node: {$address}"); + } catch (\Exception $e) { + $this->log("Failed to poll hub node {$address}: " . $e->getMessage(), 'ERROR'); + $results[$address] = [ + 'success' => false, + 'error_code' => 'errors.binkp.hub_node.poll_failed', + 'error' => 'Failed to poll hub node', + ]; + } + } + + return $results; + } + public function testConnection($hostname, $port = 24554, $timeout = 30) { $this->log("Testing connection to {$hostname}:{$port}"); diff --git a/src/Binkp/Protocol/BinkpSession.php b/src/Binkp/Protocol/BinkpSession.php index f583a9bf3..c2b31f83e 100644 --- a/src/Binkp/Protocol/BinkpSession.php +++ b/src/Binkp/Protocol/BinkpSession.php @@ -336,6 +336,9 @@ public function processSession() // Deliver any hold-directory files queued for the connecting node (runs for both roles) $this->sendHoldFiles(); + + // Deliver any hub_node_outbound packets queued for this subordinate (runs for both roles) + $this->sendHubNodeOutbound(); } if ($this->isOriginator) { @@ -681,9 +684,10 @@ private function processHandshakeFrame(BinkpFrame $frame) // Trim whitespace first to handle leading/trailing spaces $addresses = array_values(array_filter(explode(' ', trim($addressData)), 'strlen')); - // Try to find a matching address in our uplinks + // Try to find a matching address among our uplinks or hub nodes/points $matchedAddress = null; $matchedAddressWithDomain = null; + $hubNodeManager = new \BinktermPHP\Hub\HubNodeManager(); foreach ($addresses as $addr) { $addr = trim($addr); $addrWithDomain = $addr; @@ -699,7 +703,7 @@ private function processHandshakeFrame(BinkpFrame $frame) $addr = substr($addr, 0, -2); } - if (!empty($addr) && $this->config->getUplinkByAddress($addr)) { + if (!empty($addr) && ($this->config->getUplinkByAddress($addr) || $hubNodeManager->getByAddress($addr))) { $matchedAddress = $addr; $matchedAddressWithDomain = $addrWithDomain; break; @@ -1113,6 +1117,394 @@ private function sendHoldFiles(): void } } + /** + * Cap on hub_node_outbound.attempts before a failed row is no longer + * retried on reconnect - keeps a genuinely poison row (e.g. malformed + * message data) from being retried forever while still surfacing it as + * 'failed' for admin visibility. + */ + private const HUB_OUTBOUND_MAX_ATTEMPTS = 10; + + /** + * Send any pending (and retry-eligible failed) hub_node_outbound rows + * queued for the connecting subordinate (node or point). Called at + * session start for both originator and answerer, mirroring + * sendFreqFiles()/sendHoldFiles(). Pending echomail/netmail rows are + * bundled into one or more multi-message .pkt files at send time (see + * chunkHubNodeBundle()/sendHubNodeOutboundBundle()), capped at + * hub_nodes.max_packet_kb per file rather than one unbounded bundle; + * TIC rows are always sent individually as their own file pair. + */ + private function sendHubNodeOutbound(): void + { + $remoteAddr = $this->remoteAddress ?? ''; + if ($remoteAddr === '' || $remoteAddr === 'unknown') { + return; + } + + try { + $hubNode = (new \BinktermPHP\Hub\HubNodeManager())->getByAddress($remoteAddr); + if (!$hubNode || !$hubNode['enabled'] || $hubNode['hold_mail']) { + return; + } + + $db = \BinktermPHP\Database::getInstance()->getPdo(); + // Retry 'failed' rows (e.g. from a session that was interrupted + // mid-transfer) on the subordinate's next connection, same as + // 'pending' rows - up to HUB_OUTBOUND_MAX_ATTEMPTS so a genuinely + // broken row doesn't retry forever. + $stmt = $db->prepare( + "SELECT id, message_type, packet_data, message_payload, tic_file_data, tic_filename, size_bytes + FROM hub_node_outbound + WHERE hub_node_id = ? + AND (status = 'pending' OR (status = 'failed' AND attempts < ?)) + ORDER BY priority ASC, next_attempt_at ASC" + ); + $stmt->execute([$hubNode['id'], self::HUB_OUTBOUND_MAX_ATTEMPTS]); + $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + if (empty($rows)) { + return; + } + + $this->log("Sending " . count($rows) . " hub node outbound packet(s) queued for {$remoteAddr}", 'INFO'); + + $markSent = $db->prepare( + "UPDATE hub_node_outbound SET status = 'sent', sent_at = NOW() WHERE id = ?" + ); + $markFailed = $db->prepare( + "UPDATE hub_node_outbound SET status = 'failed', attempts = attempts + 1, error_message = ? WHERE id = ?" + ); + + // Bundle every pending echomail/netmail row into one or more + // multi-message .pkt files at send time (rather than one file + // per row) - message_payload holds the createOutboundPacket()-ready + // array captured at queue time. Rows queued before this column + // existed (message_payload NULL) fall back to sending their + // individually pre-rendered packet_data, same as before. + $bundleMessages = []; + $bundleIds = []; + $bundleSizes = []; + $legacyRows = []; + + foreach ($rows as $row) { + if (($row['message_type'] ?? 'echomail') === 'tic') { + $this->sendHubNodeTicRow($row, $remoteAddr, $markSent, $markFailed); + continue; + } + + $payloadJson = $row['message_payload']; + if (is_resource($payloadJson)) { + $payloadJson = stream_get_contents($payloadJson); + } + $packetMessage = $payloadJson !== null ? json_decode((string)$payloadJson, true) : null; + + if (is_array($packetMessage)) { + $bundleMessages[] = $packetMessage; + $bundleIds[] = (int)$row['id']; + $bundleSizes[] = (int)($row['size_bytes'] ?? 0); + } else { + $legacyRows[] = $row; + } + } + + if (!empty($bundleMessages)) { + $maxBytes = (int)($hubNode['max_packet_kb'] ?? 0) * 1024; + $chunks = $this->chunkHubNodeBundle($bundleMessages, $bundleIds, $bundleSizes, $maxBytes); + $compress = !empty($hubNode['compress_outbound']); + foreach ($chunks as $chunk) { + $this->sendHubNodeOutboundBundle($chunk['messages'], $chunk['ids'], $remoteAddr, $markSent, $markFailed, $compress); + } + } + + foreach ($legacyRows as $row) { + $this->sendHubNodeOutboundLegacyRow($row, $remoteAddr, $markSent, $markFailed); + } + } catch (\Exception $e) { + $this->log("sendHubNodeOutbound error: " . $e->getMessage(), 'ERROR'); + } + } + + /** + * Split bundled messages into one or more chunks so no single .pkt + * exceeds $maxBytes (hub_nodes.max_packet_kb * 1024; 0/unlimited returns + * everything as one chunk, the pre-existing behavior). Greedy bin-packing + * using hub_node_outbound.size_bytes (each row's own pre-bundle packet + * size) as a per-message size estimate - close enough since the only + * per-chunk overhead beyond the sum of message sizes is one shared + * packet header/terminator. A single message larger than $maxBytes still + * gets its own chunk rather than being dropped, so an oversized message + * doesn't stall the queue. + * + * @param array> $messages + * @param int[] $ids + * @param int[] $sizes + * @return array>, ids: int[]}> + */ + private function chunkHubNodeBundle(array $messages, array $ids, array $sizes, int $maxBytes): array + { + if ($maxBytes <= 0) { + return [['messages' => $messages, 'ids' => $ids]]; + } + + $chunks = []; + $currentMessages = []; + $currentIds = []; + $currentBytes = 0; + + foreach ($messages as $i => $message) { + $size = $sizes[$i] ?? 0; + if (!empty($currentMessages) && $currentBytes + $size > $maxBytes) { + $chunks[] = ['messages' => $currentMessages, 'ids' => $currentIds]; + $currentMessages = []; + $currentIds = []; + $currentBytes = 0; + } + $currentMessages[] = $message; + $currentIds[] = $ids[$i]; + $currentBytes += $size; + } + + if (!empty($currentMessages)) { + $chunks[] = ['messages' => $currentMessages, 'ids' => $currentIds]; + } + + return $chunks; + } + + /** + * Build and send one combined .pkt carrying every bundled message, then + * mark all of $bundleIds sent (or all failed together, so a partial + * bundle is never left half-delivered) - matches the semantics of + * BinkdProcessor::createOutboundPacket() writing one header/terminator + * around N message records. + * + * When $compress is true, the .pkt is packed into a ZIP arcmail bundle + * (see zipOutboundPacket()) before sending, since bundled hub node + * outbound is the case most likely to produce large multi-message + * packets. Compression is opt-in per downlink (hub_nodes.compress_outbound) + * because it's only safe when we know the receiving mailer auto-detects + * bundle extensions the way BinkdProcessor::processPacket() does. + * + * @param array> $bundleMessages + * @param int[] $bundleIds + */ + private function sendHubNodeOutboundBundle( + array $bundleMessages, + array $bundleIds, + string $remoteAddr, + \PDOStatement $markSent, + \PDOStatement $markFailed, + bool $compress = false + ): void { + $tmpPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . substr(uniqid(), -8) . '.pkt'; + $sendPath = $tmpPath; + + try { + (new \BinktermPHP\BinkdProcessor())->createOutboundPacket($bundleMessages, $remoteAddr, $tmpPath); + + if ($compress) { + $zipPath = $this->zipOutboundPacket($tmpPath); + if ($zipPath !== null) { + $sendPath = $zipPath; + $this->log(sprintf( + "Hub node outbound: compressed bundled packet %d -> %d bytes for {$remoteAddr}", + filesize($tmpPath), + filesize($zipPath) + ), 'INFO'); + } + } + + $wireName = basename($sendPath); + + // See sendHubNodeOutbound()'s per-row version below for why this + // is registered as an "extra" file rather than via addExtraFile(). + $this->extraOutboundFilesByName[$wireName] = $sendPath; + $this->sendFile($sendPath); + + foreach ($bundleIds as $id) { + $markSent->execute([$id]); + } + $this->log( + "Hub node outbound: sent bundled packet with " . count($bundleIds) . " message(s) (#" . implode(',', $bundleIds) . ") to {$remoteAddr}", + 'INFO' + ); + } catch (\Exception $e) { + foreach ($bundleIds as $id) { + $markFailed->execute([$e->getMessage(), $id]); + } + $this->log("Hub node outbound: failed to send bundled packet (#" . implode(',', $bundleIds) . ") to {$remoteAddr}: " . $e->getMessage(), 'ERROR'); + } finally { + @unlink($tmpPath); + if ($sendPath !== $tmpPath) { + @unlink($sendPath); + } + } + } + + /** + * Pack a single .pkt into a ZIP arcmail bundle named with the FTS-5001 + * day-of-week extension (.mo0, .tu0, etc.) that BinkdProcessor::processPacket() + * already recognizes and unpacks on the receiving side. Returns null + * (caller falls back to sending the raw .pkt) if ZipArchive is + * unavailable or the archive can't be built - a missing/broken archiver + * must never block mail flow. + */ + private function zipOutboundPacket(string $pktPath): ?string + { + if (!class_exists(\ZipArchive::class)) { + $this->log("Hub node outbound: ZipArchive extension unavailable, sending uncompressed", 'WARNING'); + return null; + } + + $days = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa']; + $ext = $days[(int)date('w')] . '0'; + $zipPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . substr(uniqid(), -8) . '.' . $ext; + + $zip = new \ZipArchive(); + if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + $this->log("Hub node outbound: failed to create ZIP bundle {$zipPath}", 'WARNING'); + return null; + } + + $added = $zip->addFile($pktPath, basename($pktPath)); + $zip->close(); + + if (!$added || !is_file($zipPath)) { + @unlink($zipPath); + $this->log("Hub node outbound: failed to add packet to ZIP bundle {$zipPath}", 'WARNING'); + return null; + } + + return $zipPath; + } + + /** + * Send a single pre-rendered hub_node_outbound row's packet_data as its + * own file - the pre-message_payload behavior, kept as a fallback for + * rows queued before that column existed. + * + * @param array $row + */ + private function sendHubNodeOutboundLegacyRow( + array $row, + string $remoteAddr, + \PDOStatement $markSent, + \PDOStatement $markFailed + ): void { + $id = (int)$row['id']; + $bytes = $row['packet_data']; + if (is_resource($bytes)) { + $bytes = stream_get_contents($bytes); + } + + // Wire filename must be 8.3-compatible (FTN convention) - this + // basename is what handleSentFileConfirmation() matches against + // the remote's M_GOT, so keep it short like other packet names + // in this codebase (see BinkdProcessor::createOutboundPacket()). + $tmpPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . substr(uniqid(), -8) . '.pkt'; + $wireName = basename($tmpPath); + try { + if (file_put_contents($tmpPath, $bytes) === false) { + throw new \Exception('Failed to write temp packet file'); + } + // Register as an "extra" file (same bucket as .req files - + // see addExtraFile()) so handleSentFileConfirmation() knows + // this filename lives outside data/outbound/ when the + // remote's M_GOT arrives, instead of logging a spurious + // "Sent file not found" warning while looking for it there. + // Deliberately not calling addExtraFile() itself - that + // also queues the file for sendFiles() to send again. + $this->extraOutboundFilesByName[$wireName] = $tmpPath; + $this->sendFile($tmpPath); + $markSent->execute([$id]); + $this->log("Hub node outbound: sent packet #{$id} to {$remoteAddr}", 'INFO'); + } catch (\Exception $e) { + $markFailed->execute([$e->getMessage(), $id]); + $this->log("Hub node outbound: failed to send packet #{$id} to {$remoteAddr}: " . $e->getMessage(), 'ERROR'); + } finally { + // Delete the temp file now (we don't keep it around waiting + // for M_GOT the way data/outbound/ files do), but leave the + // extraOutboundFilesByName entry in place - the remote's + // M_GOT for this filename can arrive well after this loop + // moves on, and handleSentFileConfirmation() needs to find + // the mapping then, not just at send time. It no-ops safely + // once file_exists() is false, same as .req files that are + // confirmed after already being cleaned up. + @unlink($tmpPath); + } + } + + /** + * Send a queued hub_node_outbound row with message_type='tic' - a TIC + * control-file + data-file pair (docs/proposals/HubPointSystemJuly2026.md + * Phase 4), rather than a single .pkt. Mirrors the .tic-pair handling in + * sendFiles() (data file first, then the .tic control file), but reads + * from hub_node_outbound's BYTEA columns instead of the outbound + * directory. + * + * @param array $row + */ + private function sendHubNodeTicRow(array $row, string $remoteAddr, \PDOStatement $markSent, \PDOStatement $markFailed): void + { + $id = (int)$row['id']; + + $ticBytes = $row['packet_data']; + if (is_resource($ticBytes)) { + $ticBytes = stream_get_contents($ticBytes); + } + $dataBytes = $row['tic_file_data']; + if (is_resource($dataBytes)) { + $dataBytes = stream_get_contents($dataBytes); + } + $origFilename = basename((string)($row['tic_filename'] ?? '')); + + if ($ticBytes === false || $ticBytes === null || $dataBytes === false || $dataBytes === null || $origFilename === '') { + $markFailed->execute(['Missing TIC payload data', $id]); + $this->log("Hub node outbound: TIC row #{$id} has incomplete payload, marking failed", 'ERROR'); + return; + } + + // Each row gets its own temp subdirectory so the data file can keep + // its original filename (required - the .tic "File" field and the + // remote's TicFileProcessor match on it) without colliding with any + // other queued row that happens to share the same original filename. + $tmpDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'hubtic_' . substr(uniqid(), -8); + $dataTmpPath = $tmpDir . DIRECTORY_SEPARATOR . $origFilename; + // TIC control filename must be 8.3-compatible, same convention as + // TicFileGenerator::createTicFile()'s randomized outbound names. + $ticTmpPath = $tmpDir . DIRECTORY_SEPARATOR . substr(uniqid(), -8) . '.tic'; + + try { + if (!@mkdir($tmpDir, 0700, true) && !is_dir($tmpDir)) { + throw new \Exception('Failed to create temp directory for TIC pair'); + } + if (file_put_contents($dataTmpPath, $dataBytes) === false) { + throw new \Exception('Failed to write temp TIC data file'); + } + if (file_put_contents($ticTmpPath, $ticBytes) === false) { + throw new \Exception('Failed to write temp TIC control file'); + } + + // See sendHubNodeOutbound() above for why these are registered as + // "extra" files rather than via addExtraFile(). + $this->extraOutboundFilesByName[basename($dataTmpPath)] = $dataTmpPath; + $this->extraOutboundFilesByName[basename($ticTmpPath)] = $ticTmpPath; + + $this->sendFile($dataTmpPath); + $this->sendFile($ticTmpPath); + $markSent->execute([$id]); + $this->log("Hub node outbound: sent TIC pair #{$id} ({$origFilename}) to {$remoteAddr}", 'INFO'); + } catch (\Exception $e) { + $markFailed->execute([$e->getMessage(), $id]); + $this->log("Hub node outbound: failed to send TIC pair #{$id} to {$remoteAddr}: " . $e->getMessage(), 'ERROR'); + } finally { + @unlink($dataTmpPath); + @unlink($ticTmpPath); + @rmdir($tmpDir); + } + } + private function sendFiles() { $outboundPath = $this->config->getOutboundPath(); @@ -1903,13 +2295,21 @@ private function validatePassword($password) } // Plain text password validation - // If we sent a challenge and got a plain password, check if fallback is allowed + // If we sent a challenge and got a plain password, check if fallback is allowed. + // Registered hub nodes/points always accept plaintext regardless of the global + // uplink security policy - they're subordinate systems under our own admin + // control, not the public FTN network the plaintext-fallback policy guards. if ($this->cramChallenge !== null) { - if (!$this->config->getAllowPlaintextFallback()) { + $isHubNode = $this->remoteAddress !== null + && (new \BinktermPHP\Hub\HubNodeManager())->getByAddress($this->remoteAddress) !== null; + + if (!$this->config->getAllowPlaintextFallback() && !$isHubNode) { $this->log("Plain text password rejected - CRAM-MD5 required", 'WARNING'); return false; } - $this->log("Accepting plain text password fallback", 'DEBUG'); + $this->log($isHubNode + ? "Accepting plain text password for registered hub node {$this->remoteAddress}" + : "Accepting plain text password fallback", 'DEBUG'); } $match = hash_equals($expectedPassword, $password); @@ -2049,10 +2449,16 @@ private function getPasswordForRemote() if ($uplink) { $this->log("Found uplink config for {$this->remoteAddress}", 'DEBUG'); return $uplink['password'] ?? ''; - } else { - $this->log("No uplink config found for {$this->remoteAddress}", 'WARNING'); - return ''; } + + $hubNode = (new \BinktermPHP\Hub\HubNodeManager())->getByAddress($this->remoteAddress); + if ($hubNode) { + $this->log("Found hub node config for {$this->remoteAddress}", 'DEBUG'); + return $hubNode['session_password'] ?? ''; + } + + $this->log("No uplink or hub node config found for {$this->remoteAddress}", 'WARNING'); + return ''; } return ''; } diff --git a/src/Binkp/Protocol/PacketInspector.php b/src/Binkp/Protocol/PacketInspector.php new file mode 100644 index 000000000..a01591668 --- /dev/null +++ b/src/Binkp/Protocol/PacketInspector.php @@ -0,0 +1,159 @@ + false, 'error' => 'Cannot open packet file']; + } + + try { + // ── Packet header (58 bytes, FTS-0001) ─────────────────────────── + $hdr = fread($handle, 60); + if (strlen($hdr) < 58) { + fclose($handle); + return ['success' => false, 'error' => 'File too small to be a valid FTS-0001 packet']; + } + + $h = unpack( + 'vorigNode/vdestNode/vyear/vmonth/vday/vhour/vminute/vsecond/' . + 'vbaud/vpacketVersion/vorigNet/vdestNet/CprodCodeLo/CrevMajor', + substr($hdr, 0, 26) + ); + + // FTS-0001: password is 8 bytes (offsets 26–33) + // origZone/destZone at 34/36, origPoint/destPoint at 50/52 + $password = rtrim(substr($hdr, 26, 8), "\x00"); + $origZone = unpack('v', substr($hdr, 34, 2))[1]; + $destZone = unpack('v', substr($hdr, 36, 2))[1]; + $origPoint = unpack('v', substr($hdr, 50, 2))[1]; + $destPoint = unpack('v', substr($hdr, 52, 2))[1]; + + $month = ($h['month'] < 12) ? $h['month'] + 1 : $h['month']; // 0-based in spec + $created = sprintf('%04d-%02d-%02d %02d:%02d:%02d', + $h['year'], $month, $h['day'], $h['hour'], $h['minute'], $h['second']); + + $fmtAddr = function(int $zone, int $net, int $node, int $point): string { + $addr = "{$zone}:{$net}/{$node}"; + if ($point > 0) $addr .= ".{$point}"; + return $addr; + }; + + $packet = [ + 'orig_address' => $fmtAddr($origZone, $h['origNet'], $h['origNode'], $origPoint), + 'dest_address' => $fmtAddr($destZone, $h['destNet'], $h['destNode'], $destPoint), + 'created' => $created, + 'has_password' => $password !== '', + 'packet_version' => $h['packetVersion'], + 'product_code' => sprintf('%02X', $h['prodCodeLo']), + 'file_size' => filesize($filepath), + ]; + + // ── Message headers ─────────────────────────────────────────────── + fseek($handle, 58); + $messages = []; + $maxMsgs = 1000; + $attrLabels = [ + 0 => 'Pvt', 1 => 'Crash', 2 => 'Rcvd', 3 => 'Sent', + 4 => 'Att', 5 => 'Trs', 6 => 'Orphn', 7 => 'K/S', + 8 => 'Local', 9 => 'Hold', 11 => 'FReq', 12 => 'RReq', + 13 => 'RRec', 14 => 'Audit', 15 => 'FUpd', + ]; + + while (!feof($handle) && count($messages) < $maxMsgs) { + $typeBytes = fread($handle, 2); + if (strlen($typeBytes) < 2) break; + $msgType = unpack('v', $typeBytes)[1]; + if ($msgType === 0) break; // end-of-packet marker + if ($msgType !== 2) break; // unexpected type + + // 12-byte message header: origNode destNode origNet destNet attr cost + $mhBytes = fread($handle, 12); + if (strlen($mhBytes) < 12) break; + $mh = unpack('vorigNode/vdestNode/vorigNet/vdestNet/vattr/vcost', $mhBytes); + + $datetime = self::pktReadString($handle, 20); + $toName = self::pktReadString($handle, 36); + $fromName = self::pktReadString($handle, 36); + $subject = self::pktReadString($handle, 72); + + // Skip message body (null-terminated) + if (!self::pktSkipBody($handle, 65536)) break; + + $flags = []; + foreach ($attrLabels as $bit => $label) { + if ($mh['attr'] & (1 << $bit)) { + $flags[] = $label; + } + } + + $cp437 = fn(string $s): string => + (@iconv('CP437', 'UTF-8//IGNORE', $s) ?: mb_convert_encoding($s, 'UTF-8', 'UTF-8')); + + $messages[] = [ + 'from' => $cp437($fromName), + 'to' => $cp437($toName), + 'subject' => $cp437($subject), + 'date' => $datetime, + 'orig_addr' => $mh['origNet'] . ':' . $mh['origNode'], + 'dest_addr' => $mh['destNet'] . ':' . $mh['destNode'], + 'flags' => $flags, + 'cost' => $mh['cost'], + ]; + } + + fclose($handle); + + return [ + 'success' => true, + 'packet' => $packet, + 'messages' => $messages, + ]; + + } catch (\Exception $e) { + if (is_resource($handle)) fclose($handle); + return ['success' => false, 'error' => $e->getMessage()]; + } + } + + /** + * Read a null-terminated string from $handle, consuming at most $maxLen bytes. + */ + private static function pktReadString($handle, int $maxLen): string + { + $result = ''; + for ($i = 0; $i < $maxLen; $i++) { + $ch = fread($handle, 1); + if ($ch === false || $ch === '' || $ch === "\x00") break; + $result .= $ch; + } + return $result; + } + + /** + * Skip a null-terminated message body, consuming at most $maxLen bytes. + * Returns false if the read failed before finding the null terminator. + */ + private static function pktSkipBody($handle, int $maxLen): bool + { + for ($i = 0; $i < $maxLen; $i++) { + $ch = fread($handle, 1); + if ($ch === false || $ch === '') return false; + if ($ch === "\x00") return true; + } + return true; // Reached limit — treat as terminated + } +} diff --git a/src/Binkp/Web/BinkpController.php b/src/Binkp/Web/BinkpController.php index a7aa6a2a3..404ef0aef 100644 --- a/src/Binkp/Web/BinkpController.php +++ b/src/Binkp/Web/BinkpController.php @@ -270,7 +270,178 @@ public function getOutboundFiles() return $this->apiErrorResponse('errors.binkp.files.outbound_failed', $e->getMessage()); } } - + + /** + * List queued hub_node_outbound rows (metadata only - packet_data is + * excluded) for the Downlink Queue tab on /binkp. Not license-gated, + * matching the ungated live queue file listing. + */ + public function getHubOutboundQueue(int $limit = 200): array + { + try { + $db = \BinktermPHP\Database::getInstance()->getPdo(); + $stmt = $db->prepare(" + SELECT hno.id, hno.hub_node_id, hn.node_address, hn.name AS node_name, hn.node_type, + hno.message_type, hno.status, hno.size_bytes, hno.priority, hno.attempts, + hno.created_at, hno.next_attempt_at, hno.sent_at, hno.error_message + FROM hub_node_outbound hno + JOIN hub_nodes hn ON hn.id = hno.hub_node_id + ORDER BY hno.created_at DESC + LIMIT ? + "); + $stmt->bindValue(1, $limit, \PDO::PARAM_INT); + $stmt->execute(); + + return [ + 'success' => true, + 'rows' => $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [], + ]; + } catch (\Exception $e) { + return $this->apiErrorResponse('errors.binkp.hub_outbound.list_failed', $e->getMessage()); + } + } + + /** + * Parse a queued hub_node_outbound packet's contents. Requires a valid + * license, matching the existing live/kept-packet inspectors. + */ + public function inspectHubOutboundPacket(int $id): array + { + if (!\BinktermPHP\License::isValid()) { + return [ + 'success' => false, + 'error_code' => 'errors.binkp.kept_packets.license_required', + 'error' => 'Viewing packets requires a registered license', + ]; + } + + $row = $this->fetchHubOutboundRow($id); + if ($row === null) { + return ['success' => false, 'error' => 'Packet not found']; + } + + if (($row['message_type'] ?? 'echomail') === 'tic') { + return $this->inspectHubOutboundTicRow($row); + } + + $bytes = $row['packet_data']; + if ($bytes === null) { + return ['success' => false, 'error' => 'Packet not found']; + } + + $tmpFile = tempnam(sys_get_temp_dir(), 'hubpkt_'); + try { + if (file_put_contents($tmpFile, $bytes) === false) { + return ['success' => false, 'error' => 'Failed to stage packet for inspection']; + } + return $this->parsePacketFull($tmpFile); + } catch (\Exception $e) { + return $this->apiErrorResponse('errors.binkp.queue.inspect_failed', $e->getMessage()); + } finally { + @unlink($tmpFile); + } + } + + /** + * Parse a queued hub_node_outbound row's TIC control-file content + * (docs/proposals/HubPointSystemJuly2026.md Phase 4) for display, + * rather than treating it as an FTS-0001 .pkt. + * + * @param array $row + */ + private function inspectHubOutboundTicRow(array $row): array + { + $ticContent = (string)($row['packet_data'] ?? ''); + $fields = ['Path' => [], 'Seenby' => [], 'LDesc' => []]; + + foreach (preg_split('/\r\n|\r|\n/', $ticContent, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $line) { + if (!preg_match('/^(\w+)\s+(.+)$/i', trim($line), $m)) { + continue; + } + $key = ucfirst(strtolower($m[1])); + $key = $key === 'Ldesc' ? 'LDesc' : $key; + $value = trim($m[2]); + + if (in_array($key, ['Path', 'Seenby', 'LDesc'], true)) { + $fields[$key][] = $value; + } else { + $fields[$key] = $value; + } + } + + return [ + 'success' => true, + 'type' => 'tic', + 'tic' => $fields, + 'data_filename' => (string)($row['tic_filename'] ?? ''), + 'data_size' => strlen((string)($row['tic_file_data'] ?? '')), + ]; + } + + /** + * Fetch a queued hub_node_outbound row's raw packet bytes and a + * download-friendly filename, for the download route (license-gated + * at the route level, matching the existing download route). For a + * message_type='tic' row, downloads the referenced data file itself + * (tic_file_data/tic_filename) rather than the .tic control text. + * + * @return array{filename:string,bytes:string}|null + */ + public function getHubOutboundPacketBytes(int $id): ?array + { + $row = $this->fetchHubOutboundRow($id); + if ($row === null) { + return null; + } + + if (($row['message_type'] ?? 'echomail') === 'tic') { + $bytes = $row['tic_file_data']; + if ($bytes === null) { + return null; + } + $filename = basename((string)($row['tic_filename'] ?? '')); + + return [ + 'filename' => $filename !== '' ? $filename : ('hub_outbound_' . $id), + 'bytes' => $bytes, + ]; + } + + $bytes = $row['packet_data']; + if ($bytes === null) { + return null; + } + + return [ + 'filename' => 'hub_outbound_' . $id . '.pkt', + 'bytes' => $bytes, + ]; + } + + /** + * @return array|null + */ + private function fetchHubOutboundRow(int $id): ?array + { + $db = \BinktermPHP\Database::getInstance()->getPdo(); + $stmt = $db->prepare("SELECT message_type, packet_data, tic_file_data, tic_filename FROM hub_node_outbound WHERE id = ?"); + $stmt->execute([$id]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + if (!$row) { + return null; + } + + if (is_resource($row['packet_data'])) { + $row['packet_data'] = stream_get_contents($row['packet_data']); + } + if (is_resource($row['tic_file_data'])) { + $row['tic_file_data'] = stream_get_contents($row['tic_file_data']); + } + + return $row; + } + public function processInbound() { try { @@ -829,146 +1000,7 @@ private function resolveKeptPacketPath(string $type, string $date, string $filen */ private function parsePacketFull(string $filepath): array { - $handle = fopen($filepath, 'rb'); - if (!$handle) { - return ['success' => false, 'error' => 'Cannot open packet file']; - } - - try { - // ── Packet header (58 bytes, FTS-0001) ─────────────────────────── - $hdr = fread($handle, 60); - if (strlen($hdr) < 58) { - fclose($handle); - return ['success' => false, 'error' => 'File too small to be a valid FTS-0001 packet']; - } - - $h = unpack( - 'vorigNode/vdestNode/vyear/vmonth/vday/vhour/vminute/vsecond/' . - 'vbaud/vpacketVersion/vorigNet/vdestNet/CprodCodeLo/CrevMajor', - substr($hdr, 0, 26) - ); - - // FTS-0001: password is 8 bytes (offsets 26–33) - // origZone/destZone at 34/36, origPoint/destPoint at 50/52 - $password = rtrim(substr($hdr, 26, 8), "\x00"); - $origZone = unpack('v', substr($hdr, 34, 2))[1]; - $destZone = unpack('v', substr($hdr, 36, 2))[1]; - $origPoint = unpack('v', substr($hdr, 50, 2))[1]; - $destPoint = unpack('v', substr($hdr, 52, 2))[1]; - - $month = ($h['month'] < 12) ? $h['month'] + 1 : $h['month']; // 0-based in spec - $created = sprintf('%04d-%02d-%02d %02d:%02d:%02d', - $h['year'], $month, $h['day'], $h['hour'], $h['minute'], $h['second']); - - $fmtAddr = function(int $zone, int $net, int $node, int $point): string { - $addr = "{$zone}:{$net}/{$node}"; - if ($point > 0) $addr .= ".{$point}"; - return $addr; - }; - - $packet = [ - 'orig_address' => $fmtAddr($origZone, $h['origNet'], $h['origNode'], $origPoint), - 'dest_address' => $fmtAddr($destZone, $h['destNet'], $h['destNode'], $destPoint), - 'created' => $created, - 'has_password' => $password !== '', - 'packet_version' => $h['packetVersion'], - 'product_code' => sprintf('%02X', $h['prodCodeLo']), - 'file_size' => filesize($filepath), - ]; - - // ── Message headers ─────────────────────────────────────────────── - fseek($handle, 58); - $messages = []; - $maxMsgs = 1000; - $attrLabels = [ - 0 => 'Pvt', 1 => 'Crash', 2 => 'Rcvd', 3 => 'Sent', - 4 => 'Att', 5 => 'Trs', 6 => 'Orphn', 7 => 'K/S', - 8 => 'Local', 9 => 'Hold', 11 => 'FReq', 12 => 'RReq', - 13 => 'RRec', 14 => 'Audit', 15 => 'FUpd', - ]; - - while (!feof($handle) && count($messages) < $maxMsgs) { - $typeBytes = fread($handle, 2); - if (strlen($typeBytes) < 2) break; - $msgType = unpack('v', $typeBytes)[1]; - if ($msgType === 0) break; // end-of-packet marker - if ($msgType !== 2) break; // unexpected type - - // 12-byte message header: origNode destNode origNet destNet attr cost - $mhBytes = fread($handle, 12); - if (strlen($mhBytes) < 12) break; - $mh = unpack('vorigNode/vdestNode/vorigNet/vdestNet/vattr/vcost', $mhBytes); - - $datetime = $this->pktReadString($handle, 20); - $toName = $this->pktReadString($handle, 36); - $fromName = $this->pktReadString($handle, 36); - $subject = $this->pktReadString($handle, 72); - - // Skip message body (null-terminated) - if (!$this->pktSkipBody($handle, 65536)) break; - - $flags = []; - foreach ($attrLabels as $bit => $label) { - if ($mh['attr'] & (1 << $bit)) { - $flags[] = $label; - } - } - - $cp437 = fn(string $s): string => - (@iconv('CP437', 'UTF-8//IGNORE', $s) ?: mb_convert_encoding($s, 'UTF-8', 'UTF-8')); - - $messages[] = [ - 'from' => $cp437($fromName), - 'to' => $cp437($toName), - 'subject' => $cp437($subject), - 'date' => $datetime, - 'orig_addr' => $mh['origNet'] . ':' . $mh['origNode'], - 'dest_addr' => $mh['destNet'] . ':' . $mh['destNode'], - 'flags' => $flags, - 'cost' => $mh['cost'], - ]; - } - - fclose($handle); - - return [ - 'success' => true, - 'packet' => $packet, - 'messages' => $messages, - ]; - - } catch (\Exception $e) { - if (is_resource($handle)) fclose($handle); - return ['success' => false, 'error' => $e->getMessage()]; - } - } - - /** - * Read a null-terminated string from $handle, consuming at most $maxLen bytes. - */ - private function pktReadString($handle, int $maxLen): string - { - $result = ''; - for ($i = 0; $i < $maxLen; $i++) { - $ch = fread($handle, 1); - if ($ch === false || $ch === '' || $ch === "\x00") break; - $result .= $ch; - } - return $result; - } - - /** - * Skip a null-terminated message body, consuming at most $maxLen bytes. - * Returns false if the read failed before finding the null terminator. - */ - private function pktSkipBody($handle, int $maxLen): bool - { - for ($i = 0; $i < $maxLen; $i++) { - $ch = fread($handle, 1); - if ($ch === false || $ch === '') return false; - if ($ch === "\x00") return true; - } - return true; // Reached limit — treat as terminated + return \BinktermPHP\Binkp\Protocol\PacketInspector::inspect($filepath); } /** diff --git a/src/Echomail/EchomailSeenBy.php b/src/Echomail/EchomailSeenBy.php new file mode 100644 index 000000000..aca2ae7c5 --- /dev/null +++ b/src/Echomail/EchomailSeenBy.php @@ -0,0 +1,259 @@ + 0, 'net' => 0, 'node' => 0, 'point' => 0, 'node_point' => '0']; + } + + $zoneParts = explode(':', $address, 2); + $zone = isset($zoneParts[1]) ? (int)trim($zoneParts[0]) : 0; + $netNode = isset($zoneParts[1]) ? trim($zoneParts[1]) : trim($zoneParts[0]); + + $netNodeParts = explode('/', $netNode, 2); + $net = (int)trim($netNodeParts[0]); + $nodePoint = isset($netNodeParts[1]) ? trim($netNodeParts[1]) : '0'; + + $nodePointParts = explode('.', $nodePoint, 2); + $node = (int)trim($nodePointParts[0]); + $point = isset($nodePointParts[1]) ? (int)trim($nodePointParts[1]) : 0; + + return [ + 'zone' => $zone, + 'net' => $net, + 'node' => $node, + 'point' => $point, + 'node_point' => $point > 0 ? $node . '.' . $point : (string)$node, + ]; + } + + /** + * True if $address has a point suffix (net/node.point form). + */ + public static function isPointAddress(string $address): bool + { + return self::parseFtnAddressParts($address)['point'] > 0; + } + + /** + * Parse SEEN-BY lines out of a bottom_kludges blob into net => [node, ...]. + * + * @return array net => list of node numbers + */ + public static function parseSeenBy(?string $bottomKludges): array + { + $seenBy = []; + if (empty($bottomKludges)) { + return $seenBy; + } + + foreach (self::splitLines($bottomKludges) as $line) { + $line = ltrim($line, "\x01"); + if (stripos($line, 'SEEN-BY:') !== 0) { + continue; + } + $rest = trim(substr($line, strlen('SEEN-BY:'))); + foreach (preg_split('/\s+/', $rest, -1, PREG_SPLIT_NO_EMPTY) as $entry) { + $parts = explode('/', $entry, 2); + if (count($parts) !== 2) { + continue; + } + $net = (int)$parts[0]; + $node = (int)$parts[1]; + if (!isset($seenBy[$net])) { + $seenBy[$net] = []; + } + if (!in_array($node, $seenBy[$net], true)) { + $seenBy[$net][] = $node; + } + } + } + + return $seenBy; + } + + /** + * True if $address's net/node (point ignored - points never appear in + * SEEN-BY) is already present. + */ + public static function seenByContains(array $seenBy, string $address): bool + { + $parts = self::parseFtnAddressParts($address); + return isset($seenBy[$parts['net']]) && in_array($parts['node'], $seenBy[$parts['net']], true); + } + + /** + * Return a copy of $seenBy with $address's net/node added. + */ + public static function addToSeenBy(array $seenBy, string $address): array + { + $parts = self::parseFtnAddressParts($address); + if (!isset($seenBy[$parts['net']])) { + $seenBy[$parts['net']] = []; + } + if (!in_array($parts['node'], $seenBy[$parts['net']], true)) { + $seenBy[$parts['net']][] = $parts['node']; + } + return $seenBy; + } + + /** + * Format a net=>[node,...] map back into SEEN-BY: lines, one line per + * net, nodes and nets sorted ascending. + */ + public static function formatSeenBy(array $seenBy): string + { + if (empty($seenBy)) { + return ''; + } + ksort($seenBy); + $lines = []; + foreach ($seenBy as $net => $nodes) { + sort($nodes); + $entries = array_map(fn($node) => "{$net}/{$node}", $nodes); + $lines[] = 'SEEN-BY: ' . implode(' ', $entries); + } + return implode("\r", $lines); + } + + /** + * Parse PATH lines out of a bottom_kludges blob into an ordered, + * deduplicated list of "net/node[.point]" hop strings. + * + * @return string[] + */ + public static function parsePath(?string $bottomKludges): array + { + $path = []; + if (empty($bottomKludges)) { + return $path; + } + + foreach (self::splitLines($bottomKludges) as $line) { + $line = ltrim($line, "\x01"); + if (stripos($line, 'PATH:') !== 0) { + continue; + } + $rest = trim(substr($line, strlen('PATH:'))); + foreach (preg_split('/\s+/', $rest, -1, PREG_SPLIT_NO_EMPTY) as $entry) { + if (!in_array($entry, $path, true)) { + $path[] = $entry; + } + } + } + + return $path; + } + + /** + * Parse Via lines (FTS-4009 relay-hop history) out of a bottom_kludges + * blob into an ordered, deduplicated list of complete "\x01Via ..." + * lines, preserved verbatim so earlier hops stay intact when we add + * our own. + * + * @return string[] + */ + public static function parseViaLines(?string $bottomKludges): array + { + $lines = []; + if (empty($bottomKludges)) { + return $lines; + } + + foreach (self::splitLines($bottomKludges) as $line) { + if (preg_match('/^\x01Via[:\s]/i', $line) && !in_array($line, $lines, true)) { + $lines[] = $line; + } + } + + return $lines; + } + + /** + * Join Via lines (as returned by parseViaLines(), plus any newly + * generated ones appended by the caller) back into bottom_kludges text. + * + * @param string[] $lines + */ + public static function formatViaLines(array $lines): string + { + return implode("\r", array_unique($lines)); + } + + /** + * True if $address (net/node[.point]) is already present in the PATH. + */ + public static function pathContains(array $path, string $address): bool + { + return in_array(self::normalizePathEntry($address), $path, true); + } + + /** + * Return a copy of $path with $address appended, unless already present. + */ + public static function addToPath(array $path, string $address): array + { + $entry = self::normalizePathEntry($address); + if (!in_array($entry, $path, true)) { + $path[] = $entry; + } + return $path; + } + + /** + * Loop guard: true if $address already occurs more than once in the raw + * (pre-dedup) parsed PATH - i.e. the message has genuinely looped back + * through us before, not merely that we're about to add ourselves once. + */ + public static function isLoop(array $rawPath, string $address): bool + { + $entry = self::normalizePathEntry($address); + $count = 0; + foreach ($rawPath as $hop) { + if ($hop === $entry) { + $count++; + } + } + return $count > 1; + } + + public static function formatPath(array $path): string + { + if (empty($path)) { + return ''; + } + return "\x01PATH: " . implode(' ', $path); + } + + private static function normalizePathEntry(string $address): string + { + $parts = self::parseFtnAddressParts($address); + return $parts['net'] . '/' . $parts['node_point']; + } + + private static function splitLines(string $blob): array + { + return preg_split('/\r\n|\r|\n/', $blob, -1, PREG_SPLIT_NO_EMPTY); + } +} diff --git a/src/FileAreaManager.php b/src/FileAreaManager.php index c68c83d8b..c613d858d 100644 --- a/src/FileAreaManager.php +++ b/src/FileAreaManager.php @@ -19,6 +19,7 @@ use PDO; use BinktermPHP\FileArea\FileAreaRuleProcessor; use BinktermPHP\Realtime\BinkStream; +use BinktermPHP\Hub\HubFanout; /** * FileAreaManager - Manages file areas and files @@ -3361,6 +3362,12 @@ private function finalizeApprovedUserUpload(int $fileId): void } catch (\Throwable $e) { $this->logger->error("Failed to generate TIC files for uploaded file: " . $e->getMessage()); } + + try { + (new HubFanout())->fanoutFile((int)$fileId); + } catch (\Throwable $e) { + $this->logger->error("Failed to fan out uploaded file to hub node subscribers: " . $e->getMessage()); + } } } diff --git a/src/Hub/HubAreafixProcessor.php b/src/Hub/HubAreafixProcessor.php new file mode 100644 index 000000000..fa8063f24 --- /dev/null +++ b/src/Hub/HubAreafixProcessor.php @@ -0,0 +1,389 @@ +db = $db ?? Database::getInstance()->getPdo(); + $this->nodeManager = $nodeManager ?? new HubNodeManager($this->db); + $this->netmailRouter = $netmailRouter ?? new HubNetmailRouter($this->db, $this->nodeManager); + } + + /** + * If $message is netmail addressed to "AreaFix" or "FileFix" at one of + * our own AKAs, process it as a robot command and return true - the + * caller must not deliver or otherwise process this message further, + * whether or not authentication/commands actually succeeded. Returns + * false for anything else so the caller falls through to normal + * delivery handling. + * + * @param array $message Raw inbound message array as built by + * BinkdProcessor (destAddr, origAddr, fromName, toName, subject, + * text, dateTime, attributes). + */ + public function processIncoming(array $message): bool + { + $toName = trim((string)($message['toName'] ?? '')); + if (strcasecmp($toName, 'AreaFix') === 0) { + $robot = 'areafix'; + } elseif (strcasecmp($toName, 'FileFix') === 0) { + $robot = 'filefix'; + } else { + return false; + } + + $destAddr = trim((string)($message['destAddr'] ?? '')); + if ($destAddr === '' || !in_array($destAddr, $this->nodeManager->getConfiguredAkas(), true)) { + // Addressed to "AreaFix"/"FileFix" but not at one of our own + // AKAs - not for us to handle (e.g. transit mail for a + // downlink's own robot). + return false; + } + + $origAddr = trim((string)($message['origAddr'] ?? '')); + $hubNode = $origAddr !== '' ? $this->nodeManager->getByAddress($origAddr) : null; + if (!$hubNode || !$hubNode['enabled']) { + // Not a registered subordinate - swallow without replying, so + // this can't be used as a backscatter oracle for unsolicited + // "AreaFix"-addressed netmail from arbitrary senders. + return true; + } + + $passwordField = $robot === 'filefix' ? 'filefix_password' : 'areafix_password'; + $expectedPassword = (string)($hubNode[$passwordField] ?? ''); + $providedPassword = (string)($message['subject'] ?? ''); + + if ($expectedPassword === '' || !hash_equals($expectedPassword, $providedPassword)) { + $this->sendReply($message, $hubNode, $robot, ['Password incorrect.']); + return true; + } + + $replyLines = []; + foreach (preg_split('/\r\n|\r|\n/', (string)($message['text'] ?? '')) ?: [] as $line) { + $line = trim($line); + if ($line === '' || ord($line[0]) === 0x01) { + continue; // blank or kludge line + } + $replyLines = array_merge($replyLines, $this->processCommand($line, $hubNode, $robot)); + } + + if (empty($replyLines)) { + $replyLines[] = 'No commands found.'; + } + + $this->sendReply($message, $hubNode, $robot, $replyLines); + + return true; + } + + /** + * @param array $hubNode + * @return string[] Reply lines produced by this single command. + */ + private function processCommand(string $line, array $hubNode, string $robot): array + { + $upper = strtoupper($line); + + if ($upper === '%HELP') { + return $this->helpLines($robot); + } + if ($upper === '%LIST') { + return $this->listLines($hubNode, $robot); + } + if ($upper === '%QUERY') { + return $this->queryLines($hubNode, $robot); + } + if ($upper === '%PAUSE') { + $this->nodeManager->update((int)$hubNode['id'], ['hold_mail' => true]); + return ['All areas paused (hold mail enabled).']; + } + if ($upper === '%RESUME') { + $this->nodeManager->update((int)$hubNode['id'], ['hold_mail' => false]); + return ['Mail resumed (hold mail disabled).']; + } + if ($upper === '%RESCAN' || str_starts_with($upper, '%RESCAN ')) { + return $this->rescan($hubNode, $robot, trim(substr($line, 7))); + } + + if ($line[0] === '+' || $line[0] === '-') { + $subscribe = $line[0] === '+'; + $tag = strtoupper(trim(substr($line, 1))); + if ($tag === '') { + return ["Invalid command: {$line}"]; + } + + return $robot === 'filefix' + ? $this->toggleFilearea($hubNode, $tag, $subscribe) + : $this->toggleEchoarea($hubNode, $tag, $subscribe); + } + + return ["Unknown command: {$line}"]; + } + + /** + * %RESCAN [AREATAG] [days] - re-queue historical echomail, going back + * $days days (default/max per HubFanout::RESCAN_DEFAULT_DAYS / + * RESCAN_MAX_DAYS). With no area tag, rescans all of the caller's + * currently subscribed areas; with one, only that area (which must be + * one the caller is currently subscribed to). Tag and day count may + * appear in either order (e.g. "%RESCAN GENERAL 30" or "%RESCAN 30 + * GENERAL"); a purely-numeric token is always taken as the day count. + * Matches common areafix conventions (Mystic et al). Echomail only - + * not meaningful for FileFix, which has no per-message history to replay. + * + * @param array $hubNode + * @return string[] + */ + private function rescan(array $hubNode, string $robot, string $args): array + { + if ($robot === 'filefix') { + return ['%RESCAN is not supported for FileFix.']; + } + + $days = null; + $areaTag = null; + foreach (preg_split('/\s+/', trim($args), -1, PREG_SPLIT_NO_EMPTY) ?: [] as $token) { + if (preg_match('/^\d{1,4}$/', $token)) { + $days = (int)$token; + } elseif ($areaTag === null) { + $areaTag = strtoupper($token); + } else { + return ["Invalid command: %RESCAN {$args}"]; + } + } + + $usedDays = $days !== null ? max(1, min($days, HubFanout::RESCAN_MAX_DAYS)) : HubFanout::RESCAN_DEFAULT_DAYS; + $fanout = new HubFanout($this->db, $this->nodeManager); + + if ($areaTag !== null) { + $area = $this->nodeManager->findSubscribedEchoareaByTag((int)$hubNode['id'], $areaTag); + if (!$area) { + return ["{$areaTag}: not currently subscribed, nothing to rescan"]; + } + $queued = $fanout->rescanForNode((int)$hubNode['id'], $days, (int)$area['id']); + return ["Rescan queued {$queued} message(s) from {$areaTag} (last {$usedDays} day(s))."]; + } + + $queued = $fanout->rescanForNode((int)$hubNode['id'], $days); + return ["Rescan queued {$queued} message(s) from the last {$usedDays} day(s)."]; + } + + /** + * @param array $hubNode + * @return string[] + */ + private function toggleEchoarea(array $hubNode, string $tag, bool $subscribe): array + { + $domain = $this->nodeManager->resolveDomain($hubNode); + $area = $this->findEligibleEchoarea($tag, $domain); + if (!$area) { + return ["{$tag}: area not found"]; + } + + $this->nodeManager->setAreaSubscription((int)$hubNode['id'], (int)$area['id'], $subscribe); + + return [$subscribe ? "{$tag}: added" : "{$tag}: removed"]; + } + + /** + * @param array $hubNode + * @return string[] + */ + private function toggleFilearea(array $hubNode, string $tag, bool $subscribe): array + { + $domain = $this->nodeManager->resolveDomain($hubNode); + $area = $this->findEligibleFilearea($tag, $domain); + if (!$area) { + return ["{$tag}: area not found"]; + } + + $this->nodeManager->setFileAreaSubscription((int)$hubNode['id'], (int)$area['id'], $subscribe); + + return [$subscribe ? "{$tag}: added" : "{$tag}: removed"]; + } + + /** + * Areas a subordinate may self-subscribe to via Areafix: active, + * non-local, non-sysop-only echoareas in the subordinate's own network + * domain (resolved from its boss AKA for points, or its own address for + * nodes). A null $domain (couldn't be resolved) matches nothing, so an + * unresolvable node/point is locked out rather than granted broad access. + */ + private function findEligibleEchoarea(string $tag, ?string $domain): ?array + { + $stmt = $this->db->prepare(" + SELECT id, tag, domain, description + FROM echoareas + WHERE UPPER(tag) = UPPER(?) AND is_active = TRUE AND COALESCE(is_local, FALSE) = FALSE + AND COALESCE(is_sysop_only, FALSE) = FALSE + AND domain IS NOT NULL AND LOWER(domain) = LOWER(?) + "); + $stmt->execute([$tag, $domain]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ?: null; + } + + /** + * Areas a subordinate may self-subscribe to via Filefix: active, + * non-local, non-private file areas in the subordinate's own network + * domain - same domain scoping as findEligibleEchoarea(). + */ + private function findEligibleFilearea(string $tag, ?string $domain): ?array + { + $stmt = $this->db->prepare(" + SELECT id, tag, domain, description + FROM file_areas + WHERE UPPER(tag) = UPPER(?) AND is_active = TRUE + AND COALESCE(is_local, FALSE) = FALSE AND COALESCE(is_private, FALSE) = FALSE + AND domain IS NOT NULL AND LOWER(domain) = LOWER(?) + "); + $stmt->execute([$tag, $domain]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ?: null; + } + + /** + * @return string[] + */ + private function helpLines(string $robot): array + { + $verb = $robot === 'filefix' ? 'file area' : 'echo area'; + + $lines = [ + 'AreaFix/FileFix command reference:', + "+TAG Subscribe to a {$verb}", + "-TAG Unsubscribe from a {$verb}", + '%LIST List available areas', + '%QUERY List your current subscriptions', + '%PAUSE Pause all areas (hold mail)', + '%RESUME Resume all areas', + ]; + + if ($robot !== 'filefix') { + $lines[] = '%RESCAN [AREATAG] [days] Re-queue echomail history (all subscribed areas, or one)'; + $lines[] = ' (default/max ' . HubFanout::RESCAN_DEFAULT_DAYS . '/' . HubFanout::RESCAN_MAX_DAYS . ' days)'; + } + + $lines[] = '%HELP This help text'; + + return $lines; + } + + /** + * @param array $hubNode + * @return string[] + */ + private function listLines(array $hubNode, string $robot): array + { + $domain = $this->nodeManager->resolveDomain($hubNode); + + if ($robot === 'filefix') { + $stmt = $this->db->prepare(" + SELECT tag, description FROM file_areas + WHERE is_active = TRUE AND COALESCE(is_local, FALSE) = FALSE AND COALESCE(is_private, FALSE) = FALSE + AND domain IS NOT NULL AND LOWER(domain) = LOWER(?) + ORDER BY tag + "); + } else { + $stmt = $this->db->prepare(" + SELECT tag, description FROM echoareas + WHERE is_active = TRUE AND COALESCE(is_local, FALSE) = FALSE AND COALESCE(is_sysop_only, FALSE) = FALSE + AND domain IS NOT NULL AND LOWER(domain) = LOWER(?) + ORDER BY tag + "); + } + $stmt->execute([$domain]); + + return $this->formatAreaRows($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [], 'No areas available.'); + } + + /** + * @param array $hubNode + * @return string[] + */ + private function queryLines(array $hubNode, string $robot): array + { + if ($robot === 'filefix') { + $stmt = $this->db->prepare(" + SELECT fa.tag, fa.description + FROM file_areas fa + JOIN hub_node_fileareas hnf ON hnf.file_area_id = fa.id + WHERE hnf.hub_node_id = ? + ORDER BY fa.tag + "); + } else { + $stmt = $this->db->prepare(" + SELECT ea.tag, ea.description + FROM echoareas ea + JOIN hub_node_areas hna ON hna.echoarea_id = ea.id + WHERE hna.hub_node_id = ? + ORDER BY ea.tag + "); + } + $stmt->execute([$hubNode['id']]); + + return $this->formatAreaRows($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [], 'You are not subscribed to any areas.'); + } + + /** + * @param array> $rows + * @return string[] + */ + private function formatAreaRows(array $rows, string $emptyMessage): array + { + if (empty($rows)) { + return [$emptyMessage]; + } + + return array_map( + static fn(array $row) => str_pad((string)$row['tag'], 20) . (string)($row['description'] ?? ''), + $rows + ); + } + + /** + * @param array $message + * @param array $hubNode + * @param string[] $bodyLines + */ + private function sendReply(array $message, array $hubNode, string $robot, array $bodyLines): void + { + $fromName = $robot === 'filefix' ? 'FileFix' : 'AreaFix'; + $destAddr = trim((string)($message['destAddr'] ?? '')); + + $packetMessage = [ + 'from_address' => $destAddr, + 'to_address' => $hubNode['node_address'], + 'from_name' => $fromName, + 'to_name' => (string)($message['fromName'] ?? 'Sysop'), + 'subject' => $fromName . ' reply', + 'message_text' => implode("\r\n", $bodyLines), + 'date_written' => date('Y-m-d H:i:s'), + 'attributes' => 0x0001, // Private + 'is_echomail' => false, + ]; + + $this->netmailRouter->buildAndEnqueue($packetMessage, $hubNode, null); + } +} diff --git a/src/Hub/HubFanout.php b/src/Hub/HubFanout.php new file mode 100644 index 000000000..13b096982 --- /dev/null +++ b/src/Hub/HubFanout.php @@ -0,0 +1,411 @@ +db = $db ?? Database::getInstance()->getPdo(); + $this->nodeManager = $nodeManager ?? new HubNodeManager($this->db); + } + + /** + * @param int $echomailId The stored echomail row to fan out. + */ + public function fanout(int $echomailId): void + { + $message = $this->loadMessage($echomailId); + if (!$message) { + return; + } + + $subscribers = $this->nodeManager->getSubscribersForArea((int)$message['echoarea_id']); + if (empty($subscribers)) { + return; + } + + $rawSeenBy = EchomailSeenBy::parseSeenBy($message['bottom_kludges']); + $rawPath = EchomailSeenBy::parsePath($message['bottom_kludges']); + $ourAddress = (string)BinkpConfig::getInstance()->getSystemAddress(); + + $processor = new BinkdProcessor(); + + foreach ($subscribers as $subscriber) { + $this->queueForSubscriber($processor, $message, $subscriber, $rawSeenBy, $rawPath, $ourAddress); + } + } + + private function loadMessage(int $echomailId): ?array + { + $stmt = $this->db->prepare(" + SELECT em.*, ea.tag AS echoarea_tag, ea.domain AS echoarea_domain + FROM echomail em + JOIN echoareas ea ON ea.id = em.echoarea_id + WHERE em.id = ? + "); + $stmt->execute([$echomailId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ?: null; + } + + /** + * @param array $message + * @param array $subscriber + * @param array $rawSeenBy + * @param string[] $rawPath + */ + private function queueForSubscriber( + BinkdProcessor $processor, + array $message, + array $subscriber, + array $rawSeenBy, + array $rawPath, + string $ourAddress + ): void { + $isPoint = $subscriber['node_type'] === HubNodeManager::TYPE_POINT; + + if ($isPoint) { + // Points are never skipped based on SEEN-BY (they never legitimately + // appear there) and never get SEEN-BY/PATH mutation - subscription + // state alone governs delivery. Pass bottom_kludges through untouched; + // echomail hop history is tracked via PATH, not Via (Via is netmail-only, + // FSC-0043 - real tossers like HPT never stamp it onto echomail). + $bottomKludges = (string)$message['bottom_kludges']; + } else { + $subscriberAddress = $subscriber['node_address']; + + // Loop guard: our own address already appears more than once in PATH. + if (EchomailSeenBy::isLoop($rawPath, $ourAddress)) { + return; + } + + // Already has it. + if (EchomailSeenBy::seenByContains($rawSeenBy, $subscriberAddress)) { + return; + } + + $seenBy = EchomailSeenBy::addToSeenBy($rawSeenBy, $ourAddress); + $seenBy = EchomailSeenBy::addToSeenBy($seenBy, $subscriberAddress); + $path = EchomailSeenBy::addToPath($rawPath, $ourAddress); + + $bottomKludges = trim(EchomailSeenBy::formatSeenBy($seenBy) . "\r" . EchomailSeenBy::formatPath($path)); + } + + $this->buildAndQueuePacket($processor, $message, $subscriber, $bottomKludges); + } + + /** + * Build a one-message outbound packet for $subscriber and insert it into + * hub_node_outbound. Shared by queueForSubscriber() (live fanout, which + * computes $bottomKludges via the SEEN-BY loop-guard/mutation above) and + * rescanForNode() (which passes the message's original bottom_kludges + * through unchanged - a rescan is a deliberate resend, not a fresh toss, + * so it must not be blocked by the "subscriber already in SEEN-BY" check). + * + * @param array $message + * @param array $subscriber + */ + private function buildAndQueuePacket( + BinkdProcessor $processor, + array $message, + array $subscriber, + string $bottomKludges + ): void { + $packetMessage = [ + 'from_address' => $message['from_address'], + 'to_address' => $subscriber['node_address'], + 'from_name' => $message['from_name'], + 'to_name' => $message['to_name'] ?? 'All', + 'subject' => $message['subject'], + 'message_text' => $message['message_text'], + 'date_written' => $message['date_written'], + 'attributes' => 0x0000, + 'is_echomail' => true, + 'echoarea_tag' => $message['echoarea_tag'], + 'echoarea_domain' => $message['echoarea_domain'], + 'kludge_lines' => $message['kludge_lines'], + 'bottom_kludges' => $bottomKludges, + 'tearline_component' => $message['tearline_component'] ?? null, + 'reply_to_id' => $message['reply_to_id'] ?? null, + // Already merged the full SEEN-BY/PATH set above; suppress + // BinkdProcessor::writeMessage()'s single-hop auto-synthesis. + 'skip_default_seenby_path' => true, + // If this message already carries a PID kludge (i.e. it arrived + // via inbound packet processing and kludge_lines is the real + // author's, not empty), it also already has that author's + // tearline embedded in message_text - suppress writeMessage()'s + // unconditional fresh PID+tearline so we don't duplicate both. + // Locally-composed posts have no PID yet and should still get + // one generated fresh (this system is the originating tosser). + 'skip_default_pid_tearline' => strpos((string)($message['kludge_lines'] ?? ''), "\x01PID:") !== false, + ]; + + $packetPath = $this->tempPacketPath(); + try { + $processor->createOutboundPacket([$packetMessage], $subscriber['node_address'], $packetPath); + $bytes = file_get_contents($packetPath); + } finally { + @unlink($packetPath); + } + + if ($bytes === false) { + return; + } + + $stmt = $this->db->prepare(" + INSERT INTO hub_node_outbound (hub_node_id, message_type, echoarea_id, echomail_id, packet_data, message_payload, size_bytes, status) + VALUES (:hub_node_id, 'echomail', :echoarea_id, :echomail_id, :packet_data, :message_payload, :size_bytes, 'pending') + "); + $stmt->bindValue(':hub_node_id', $subscriber['id'], PDO::PARAM_INT); + $stmt->bindValue(':echoarea_id', $message['echoarea_id'], PDO::PARAM_INT); + $stmt->bindValue(':echomail_id', $message['id'], PDO::PARAM_INT); + $stmt->bindValue(':packet_data', $bytes, PDO::PARAM_LOB); + $stmt->bindValue(':message_payload', json_encode($packetMessage), PDO::PARAM_STR); + $stmt->bindValue(':size_bytes', strlen($bytes), PDO::PARAM_INT); + $stmt->execute(); + } + + /** + * Default lookback window for %RESCAN when no day count is given, + * matching Mystic's areafix RESCAN default of roughly 6 months. + */ + public const RESCAN_DEFAULT_DAYS = 182; + + /** Upper bound on %RESCAN's day count, to keep a mistyped huge number from queueing years of mail. */ + public const RESCAN_MAX_DAYS = 3650; + + /** + * Re-queue historical echomail into hub_node_outbound for a single hub + * node, going back $days days (server AreaFix %RESCAN command). With + * $echoareaId null, covers all of the node's currently subscribed + * (active, non-paused) echoareas; with it set, only that one area + * (caller must have already verified the node is subscribed to it - + * see HubNodeManager::findSubscribedEchoareaByTag()). Unlike fanout(), + * this always resends every matching message regardless of SEEN-BY - + * it's an explicit request to receive again, not a fresh toss, and + * points are already exempt from SEEN-BY entirely (see queueForSubscriber()). + * + * @return int Number of messages queued. + */ + public function rescanForNode(int $hubNodeId, ?int $days = null, ?int $echoareaId = null): int + { + $days = $days !== null ? max(1, min($days, self::RESCAN_MAX_DAYS)) : self::RESCAN_DEFAULT_DAYS; + + $subscriber = $this->nodeManager->getById($hubNodeId); + if (!$subscriber || !$subscriber['enabled']) { + return 0; + } + + $echoareaIds = $echoareaId !== null ? [$echoareaId] : $this->nodeManager->getSubscribedEchoareaIds($hubNodeId); + if (empty($echoareaIds)) { + return 0; + } + + $processor = new BinkdProcessor(); + $queued = 0; + + foreach ($echoareaIds as $echoareaId) { + foreach ($this->loadRecentMessages($echoareaId, $days) as $message) { + $this->buildAndQueuePacket($processor, $message, $subscriber, (string)$message['bottom_kludges']); + $queued++; + } + } + + return $queued; + } + + /** + * @return array> + */ + private function loadRecentMessages(int $echoareaId, int $days): array + { + $stmt = $this->db->prepare(" + SELECT em.*, ea.tag AS echoarea_tag, ea.domain AS echoarea_domain + FROM echomail em + JOIN echoareas ea ON ea.id = em.echoarea_id + WHERE em.echoarea_id = ? + AND em.date_received >= (NOW() AT TIME ZONE 'UTC') - make_interval(days => ?) + ORDER BY em.date_received ASC + "); + $stmt->execute([$echoareaId, $days]); + + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + private function tempPacketPath(): string + { + return sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'hubfanout_' . uniqid('', true) . '.pkt'; + } + + /** + * Fan a stored file (upload, or inbound TIC storage) out to subscribed + * hub_nodes, enqueueing one TIC control-file + data-file pair per + * subscriber into hub_node_outbound (message_type='tic'). This is + * Phase 4 of docs/proposals/HubPointSystemJuly2026.md. + * + * @param int $fileId The stored files.id row to fan out. + */ + public function fanoutFile(int $fileId): void + { + $file = $this->loadFile($fileId); + if (!$file) { + return; + } + + $fileArea = $this->loadFileArea((int)$file['file_area_id']); + if (!$fileArea) { + return; + } + + $subscribers = $this->nodeManager->getSubscribersForFileArea((int)$file['file_area_id']); + if (empty($subscribers)) { + return; + } + + if (!file_exists($file['storage_path'])) { + return; + } + + $existingSeenBy = $this->splitAddressList($file['tic_seenby'] ?? ''); + $existingPath = $this->splitAddressList($file['tic_path'] ?? ''); + $ourAddress = (string)BinkpConfig::getInstance()->getSystemAddress(); + + $ticGenerator = new TicFileGenerator(); + + foreach ($subscribers as $subscriber) { + $this->queueFileForSubscriber($ticGenerator, $file, $fileArea, $subscriber, $existingSeenBy, $existingPath, $ourAddress); + } + } + + private function loadFile(int $fileId): ?array + { + $stmt = $this->db->prepare("SELECT * FROM files WHERE id = ? AND status = 'approved'"); + $stmt->execute([$fileId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ?: null; + } + + private function loadFileArea(int $fileAreaId): ?array + { + $stmt = $this->db->prepare("SELECT * FROM file_areas WHERE id = ?"); + $stmt->execute([$fileAreaId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ?: null; + } + + /** + * @param array $file + * @param array $fileArea + * @param array $subscriber + * @param string[] $existingSeenBy + * @param string[] $existingPath + */ + private function queueFileForSubscriber( + TicFileGenerator $ticGenerator, + array $file, + array $fileArea, + array $subscriber, + array $existingSeenBy, + array $existingPath, + string $ourAddress + ): void { + $subscriberAddress = $subscriber['node_address']; + + // Loop guard: skip if the subscriber has already seen this file + // (mirrors SEEN-BY loop prevention for echomail). + if ($this->addressListContains($existingSeenBy, $subscriberAddress)) { + return; + } + + $seenBy = $this->addAddressToList($existingSeenBy, $ourAddress); + $seenBy = $this->addAddressToList($seenBy, $subscriberAddress); + $path = $this->addAddressToList($existingPath, $ourAddress); + + $ticContent = $ticGenerator->buildTicContentForDownlink( + $file, + $fileArea, + $file['filename'], + $ourAddress, + $subscriberAddress, + $path, + $seenBy, + (string)($fileArea['password'] ?? '') + ); + + $dataBytes = @file_get_contents($file['storage_path']); + if ($dataBytes === false) { + return; + } + + $stmt = $this->db->prepare(" + INSERT INTO hub_node_outbound (hub_node_id, message_type, file_id, packet_data, tic_file_data, tic_filename, size_bytes, status) + VALUES (:hub_node_id, 'tic', :file_id, :packet_data, :tic_file_data, :tic_filename, :size_bytes, 'pending') + "); + $stmt->bindValue(':hub_node_id', $subscriber['id'], PDO::PARAM_INT); + $stmt->bindValue(':file_id', $file['id'], PDO::PARAM_INT); + $stmt->bindValue(':packet_data', $ticContent, PDO::PARAM_LOB); + $stmt->bindValue(':tic_file_data', $dataBytes, PDO::PARAM_LOB); + $stmt->bindValue(':tic_filename', $file['filename']); + $stmt->bindValue(':size_bytes', strlen($dataBytes) + strlen($ticContent), PDO::PARAM_INT); + $stmt->execute(); + } + + /** + * @return string[] Lowercase-normalized, deduplicated FTN addresses. + */ + private function splitAddressList(?string $value): array + { + if (empty($value)) { + return []; + } + $parts = preg_split('/\s+/', trim($value), -1, PREG_SPLIT_NO_EMPTY); + + return array_values(array_unique(array_map('strtolower', $parts))); + } + + /** + * @param string[] $list + */ + private function addressListContains(array $list, string $address): bool + { + return in_array(strtolower(trim($address)), $list, true); + } + + /** + * @param string[] $list + * @return string[] + */ + private function addAddressToList(array $list, string $address): array + { + $normalized = strtolower(trim($address)); + if (!in_array($normalized, $list, true)) { + $list[] = $normalized; + } + + return $list; + } +} diff --git a/src/Hub/HubNetmailRouter.php b/src/Hub/HubNetmailRouter.php new file mode 100644 index 000000000..fe30aed68 --- /dev/null +++ b/src/Hub/HubNetmailRouter.php @@ -0,0 +1,283 @@ +db = $db ?? Database::getInstance()->getPdo(); + $this->nodeManager = $nodeManager ?? new HubNodeManager($this->db); + } + + /** + * If $message's destination address belongs to a registered, enabled + * hub node/point with allow_inbound_netmail, enqueue it for delivery + * and return true. Returns false (no side effects) otherwise, so the + * caller falls through to the existing local-delivery logic. Gated + * solely by the per-node allow_inbound_netmail flag - this is core + * "deliver mail addressed to a registered downlink" functionality, not + * an opt-in relay feature, so (unlike an earlier version of this + * method) there is no separate global flag to also remember to enable. + * + * @param array $message Raw inbound message array as built by + * BinkdProcessor (destAddr, origAddr, fromName, toName, subject, + * text, dateTime, attributes). + */ + public function routeIfHubNode(array $message): bool + { + $destAddr = trim((string)($message['destAddr'] ?? '')); + if ($destAddr === '') { + return false; + } + + $hubNode = $this->nodeManager->getByAddress($destAddr); + if (!$hubNode || !$hubNode['enabled'] || $hubNode['hold_mail'] || !$hubNode['allow_inbound_netmail']) { + return false; + } + + [$bodyText, $kludgeText, $bottomKludgeText] = $this->splitKludges((string)($message['text'] ?? '')); + + $dateWritten = null; + $parsed = strtotime((string)($message['dateTime'] ?? '')); + if ($parsed !== false) { + $dateWritten = date('Y-m-d H:i:s', $parsed); + } + + // Record this hop, same as HubFanout does for echomail (FTS-4009). + $ourAddress = (string)(BinkpConfig::getInstance()->getOriginAddressByDestination($hubNode['node_address']) + ?: BinkpConfig::getInstance()->getSystemAddress()); + $viaLines = EchomailSeenBy::parseViaLines($bottomKludgeText); + $viaLines[] = \generateViaLine($ourAddress); + + $packetMessage = [ + 'from_address' => $message['origAddr'] ?? '', + 'to_address' => $hubNode['node_address'], + 'from_name' => $message['fromName'] ?? '', + 'to_name' => $message['toName'] ?? '', + 'subject' => $message['subject'] ?? '', + 'message_text' => $bodyText, + 'kludge_lines' => $kludgeText, + 'bottom_kludges' => EchomailSeenBy::formatViaLines($viaLines), + 'date_written' => $dateWritten, + 'attributes' => $message['attributes'] ?? 0, + 'is_echomail' => false, + // The preserved kludges already carry the true originator's PID, + // and message_text already has their tearline embedded - don't + // let writeMessage() add a second set on top (see HubFanout.php + // for the same reasoning on the echomail side). + 'skip_default_pid_tearline' => strpos($kludgeText, "\x01PID:") !== false, + ]; + + return $this->buildAndEnqueue($packetMessage, $hubNode, null); + } + + /** + * If $message's to_address (already shaped for + * BinkdProcessor::createOutboundPacket(), i.e. a netmail table row) + * belongs to a registered, enabled hub node/point with allow_outbound, + * enqueue it directly into hub_node_outbound and return true - instead + * of letting it fall into uplink network-pattern routing, which has no + * knowledge of registered downlinks and would send it to whatever + * uplink's pattern happens to match the destination's zone/net. + * + * @param array $message A netmail table row (or equivalently-shaped + * array) with to_address/from_address/from_name/to_name/subject/ + * message_text/attributes/date_written/kludge_lines. + */ + public function routeOutboundIfHubNode(array $message, int $netmailId): bool + { + $toAddr = trim((string)($message['to_address'] ?? '')); + if ($toAddr === '') { + return false; + } + + $hubNode = $this->nodeManager->getByAddress($toAddr); + if (!$hubNode || !$hubNode['enabled'] || $hubNode['hold_mail'] || !$hubNode['allow_outbound']) { + return false; + } + + $packetMessage = $message; + $packetMessage['to_address'] = $hubNode['node_address']; + $packetMessage['is_echomail'] = false; + // Usually a no-op (locally-composed mail has no PID yet, so this + // stays false and writeMessage() generates one fresh as normal) - + // but guards the same duplication if this row's kludge_lines + // somehow already carries one (see routeIfHubNode()). + $packetMessage['skip_default_pid_tearline'] = strpos((string)($message['kludge_lines'] ?? ''), "\x01PID:") !== false; + + // Delivering to a downlink is a hop from its perspective even when + // we originated the message ourselves - mirrors HubFanout treating + // locally-posted echomail the same way for SEEN-BY/PATH. + $ourAddress = (string)(BinkpConfig::getInstance()->getOriginAddressByDestination($hubNode['node_address']) + ?: BinkpConfig::getInstance()->getSystemAddress()); + $viaLines = EchomailSeenBy::parseViaLines((string)($message['bottom_kludges'] ?? '')); + $viaLines[] = \generateViaLine($ourAddress); + $packetMessage['bottom_kludges'] = EchomailSeenBy::formatViaLines($viaLines); + + return $this->buildAndEnqueue($packetMessage, $hubNode, $netmailId); + } + + /** + * If $message's origin address belongs to a registered, enabled hub + * node/point with allow_inbound_netmail, and the destination is neither + * us nor another registered hub node (i.e. it fell through to + * BinkdProcessor::storeNetmail()'s undeliverable path), relay it onward + * via the normal uplink-routing outbound queue and return true. + * + * @param array $message Raw inbound message array as built by + * BinkdProcessor (destAddr, origAddr, fromName, toName, subject, + * text, dateTime, attributes). + */ + public function relayIfFromHubNode(array $message): bool + { + $origAddr = trim((string)($message['origAddr'] ?? '')); + $destAddr = trim((string)($message['destAddr'] ?? '')); + if ($origAddr === '' || $destAddr === '') { + return false; + } + + $hubNode = $this->nodeManager->getByAddress($origAddr); + if (!$hubNode || !$hubNode['enabled'] || $hubNode['hold_mail'] || !$hubNode['allow_inbound_netmail']) { + return false; + } + + [$bodyText, $kludgeText, $bottomKludgeText] = $this->splitKludges((string)($message['text'] ?? '')); + + $dateWritten = null; + $parsed = strtotime((string)($message['dateTime'] ?? '')); + if ($parsed !== false) { + $dateWritten = date('Y-m-d H:i:s', $parsed); + } + + // Route via the same uplink-network-pattern lookup used for locally + // composed netmail - the packet header addresses the uplink hop, + // while the message envelope below keeps the true orig/dest so the + // uplink forwards it onward correctly. + $uplink = BinkpConfig::getInstance()->getUplinkForDestination($destAddr); + $routeAddress = $uplink ? $uplink['address'] : $destAddr; + + // Record this hop (FTS-4009), same as routeIfHubNode(). + $ourAddress = (string)(BinkpConfig::getInstance()->getOriginAddressByDestination($routeAddress) + ?: BinkpConfig::getInstance()->getSystemAddress()); + $viaLines = EchomailSeenBy::parseViaLines($bottomKludgeText); + $viaLines[] = \generateViaLine($ourAddress); + + $packetMessage = [ + 'from_address' => $origAddr, + 'to_address' => $destAddr, + 'from_name' => $message['fromName'] ?? '', + 'to_name' => $message['toName'] ?? '', + 'subject' => $message['subject'] ?? '', + 'message_text' => $bodyText, + 'kludge_lines' => $kludgeText, + 'bottom_kludges' => EchomailSeenBy::formatViaLines($viaLines), + 'date_written' => $dateWritten, + 'attributes' => $message['attributes'] ?? 0, + 'is_echomail' => false, + // See routeIfHubNode() - preserved kludges/text already carry the + // true originator's PID and tearline. + 'skip_default_pid_tearline' => strpos($kludgeText, "\x01PID:") !== false, + ]; + + (new BinkdProcessor())->createOutboundPacket([$packetMessage], $routeAddress); + + return true; + } + + /** + * Build a .pkt from $packetMessage addressed to $hubNode and insert it + * into hub_node_outbound. Shared by both routing directions, and reused + * by HubAreafixProcessor to deliver Areafix/Filefix robot replies + * (Phase 5 of docs/proposals/HubPointSystemJuly2026.md) - hence public. + * + * @param array $hubNode + */ + public function buildAndEnqueue(array $packetMessage, array $hubNode, ?int $netmailId): bool + { + $tmpPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'hubnetmail_' . uniqid('', true) . '.pkt'; + try { + (new BinkdProcessor())->createOutboundPacket([$packetMessage], $hubNode['node_address'], $tmpPath); + $bytes = file_get_contents($tmpPath); + } finally { + @unlink($tmpPath); + } + + if ($bytes === false) { + return false; + } + + $stmt = $this->db->prepare(" + INSERT INTO hub_node_outbound (hub_node_id, message_type, netmail_id, packet_data, message_payload, size_bytes, status) + VALUES (:hub_node_id, 'netmail', :netmail_id, :packet_data, :message_payload, :size_bytes, 'pending') + "); + $stmt->bindValue(':hub_node_id', $hubNode['id'], PDO::PARAM_INT); + $stmt->bindValue(':netmail_id', $netmailId, $netmailId !== null ? PDO::PARAM_INT : PDO::PARAM_NULL); + $stmt->bindValue(':packet_data', $bytes, PDO::PARAM_LOB); + $stmt->bindValue(':message_payload', json_encode($packetMessage), PDO::PARAM_STR); + $stmt->bindValue(':size_bytes', strlen($bytes), PDO::PARAM_INT); + $stmt->execute(); + + return true; + } + + /** + * Split raw FTN message text into [body, top kludges, bottom kludges], + * preserving original kludge lines (MSGID, INTL, Via, etc.) verbatim for + * relay rather than regenerating them. Via and PATH lines are separated + * into the third slot to match BinkdProcessor::storeNetmail()'s own + * bottom_kludges classification (FTS-4009 - Via/PATH go after the + * message text, everything else stays at the top). + * + * @return array{0:string,1:string,2:string} + */ + private function splitKludges(string $text): array + { + $lines = preg_split('/\r\n|\r|\n/', $text) ?: []; + $body = []; + $kludges = []; + $bottomKludges = []; + + foreach ($lines as $line) { + if (strlen($line) > 0 && ord($line[0]) === 0x01) { + if (preg_match('/^\x01Via[:\s]/i', $line) || preg_match('/^\x01PATH:/i', $line)) { + $bottomKludges[] = $line; + } else { + $kludges[] = $line; + } + } else { + $body[] = $line; + } + } + + return [implode("\n", $body), implode("\n", $kludges), implode("\n", $bottomKludges)]; + } +} diff --git a/src/Hub/HubNodeManager.php b/src/Hub/HubNodeManager.php new file mode 100644 index 000000000..6ec962e9b --- /dev/null +++ b/src/Hub/HubNodeManager.php @@ -0,0 +1,595 @@ +db = $db ?? Database::getInstance()->getPdo(); + } + + /** + * @return array> + */ + public function getAll(?string $type = null): array + { + if ($type !== null) { + $stmt = $this->db->prepare("SELECT " . self::COLUMNS . " FROM hub_nodes hn WHERE hn.node_type = ? ORDER BY hn.node_type, hn.node_address"); + $stmt->execute([$type]); + } else { + $stmt = $this->db->query("SELECT " . self::COLUMNS . " FROM hub_nodes hn ORDER BY hn.node_type, hn.node_address"); + } + + return array_map([$this, 'normalizeRow'], $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []); + } + + public function getById(int $id): ?array + { + $stmt = $this->db->prepare("SELECT " . self::COLUMNS . " FROM hub_nodes hn WHERE hn.id = ? LIMIT 1"); + $stmt->execute([$id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? $this->normalizeRow($row) : null; + } + + public function getByAddress(string $address): ?array + { + $stmt = $this->db->prepare("SELECT " . self::COLUMNS . " FROM hub_nodes hn WHERE hn.node_address = ? LIMIT 1"); + $stmt->execute([trim($address)]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $row ? $this->normalizeRow($row) : null; + } + + public function create(array $data): array + { + $prepared = $this->prepareFields($data); + + if ($this->getByAddress($prepared['node_address']) !== null) { + throw new \InvalidArgumentException('A hub node with that address already exists'); + } + + $stmt = $this->db->prepare(" + INSERT INTO hub_nodes ( + node_type, node_address, boss_address, point_number, name, sysop_name, + session_password, packet_password, areafix_password, filefix_password, + inet_host, port, enabled, allow_inbound, + allow_outbound, allow_inbound_echomail, allow_inbound_netmail, max_packet_kb, + hold_mail, compress_outbound, queue_retention_days, capability_flags, notes + ) VALUES ( + :node_type, :node_address, :boss_address, :point_number, :name, :sysop_name, + :session_password, :packet_password, :areafix_password, :filefix_password, + :inet_host, :port, :enabled, :allow_inbound, + :allow_outbound, :allow_inbound_echomail, :allow_inbound_netmail, :max_packet_kb, + :hold_mail, :compress_outbound, :queue_retention_days, :capability_flags, :notes + ) + RETURNING id + "); + $stmt->execute($this->bindable($prepared)); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return $this->getById((int)($row['id'] ?? 0)) ?? []; + } + + public function update(int $id, array $data): array + { + $existing = $this->getById($id); + if (!$existing) { + throw new \InvalidArgumentException('Hub node not found'); + } + + $prepared = $this->prepareFields(array_merge($existing, $data)); + + $conflict = $this->getByAddress($prepared['node_address']); + if ($conflict !== null && (int)$conflict['id'] !== $id) { + throw new \InvalidArgumentException('A hub node with that address already exists'); + } + + $stmt = $this->db->prepare(" + UPDATE hub_nodes SET + node_type = :node_type, + node_address = :node_address, + boss_address = :boss_address, + point_number = :point_number, + name = :name, + sysop_name = :sysop_name, + session_password = :session_password, + packet_password = :packet_password, + areafix_password = :areafix_password, + filefix_password = :filefix_password, + inet_host = :inet_host, + port = :port, + enabled = :enabled, + allow_inbound = :allow_inbound, + allow_outbound = :allow_outbound, + allow_inbound_echomail = :allow_inbound_echomail, + allow_inbound_netmail = :allow_inbound_netmail, + max_packet_kb = :max_packet_kb, + hold_mail = :hold_mail, + compress_outbound = :compress_outbound, + queue_retention_days = :queue_retention_days, + capability_flags = :capability_flags, + notes = :notes + WHERE id = :id + "); + $bindable = $this->bindable($prepared); + $bindable['id'] = $id; + $stmt->execute($bindable); + + return $this->getById($id) ?? []; + } + + public function delete(int $id): void + { + if (!$this->getById($id)) { + throw new \InvalidArgumentException('Hub node not found'); + } + + $stmt = $this->db->prepare("DELETE FROM hub_nodes WHERE id = ?"); + $stmt->execute([$id]); + } + + /** + * All echoareas with the subscription status (and pause flag) for a given hub node, + * scoped to areas in the node's own network domain (resolveDomain()) - local areas + * (no domain, or a literal 'local'-ish domain value) are never offered since a hub + * node/point only ever exchanges mail within one real network domain. Areas the + * node is already subscribed to are always included regardless of domain, even if + * that subscription predates domain scoping or was assigned cross-domain by an + * admin - otherwise a save via bulkSetAreaSubscriptions() (a full replace of + * whatever list this returns) would silently drop it. + * + * @return array> + */ + public function getAreaSubscriptions(int $hubNodeId): array + { + $hubNode = $this->getById($hubNodeId); + if (!$hubNode) { + throw new \InvalidArgumentException('Hub node not found'); + } + $domain = $this->resolveDomain($hubNode); + + $stmt = $this->db->prepare(" + SELECT ea.id AS echoarea_id, ea.tag, ea.domain, ea.description, + hna.id AS subscription_id, hna.paused, hna.subscribed_at + FROM echoareas ea + LEFT JOIN hub_node_areas hna ON hna.echoarea_id = ea.id AND hna.hub_node_id = ? + WHERE ea.is_active = TRUE + AND ( + hna.id IS NOT NULL + OR (ea.domain IS NOT NULL AND ea.domain != '' AND LOWER(ea.domain) = LOWER(?)) + ) + ORDER BY ea.domain, ea.tag + "); + $stmt->execute([$hubNodeId, $domain]); + + return array_map(function (array $row) { + $row['echoarea_id'] = (int)$row['echoarea_id']; + $row['subscribed'] = $row['subscription_id'] !== null; + $row['paused'] = filter_var($row['paused'] ?? false, FILTER_VALIDATE_BOOLEAN); + return $row; + }, $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []); + } + + public function setAreaSubscription(int $hubNodeId, int $echoareaId, bool $subscribed, bool $paused = false): void + { + if (!$this->getById($hubNodeId)) { + throw new \InvalidArgumentException('Hub node not found'); + } + + if (!$subscribed) { + $stmt = $this->db->prepare("DELETE FROM hub_node_areas WHERE hub_node_id = ? AND echoarea_id = ?"); + $stmt->execute([$hubNodeId, $echoareaId]); + return; + } + + $stmt = $this->db->prepare(" + INSERT INTO hub_node_areas (hub_node_id, echoarea_id, paused) + VALUES (?, ?, ?) + ON CONFLICT (hub_node_id, echoarea_id) DO UPDATE SET paused = EXCLUDED.paused + "); + $stmt->execute([$hubNodeId, $echoareaId, $paused ? 'true' : 'false']); + } + + /** + * Replace the full subscription set for a hub node in one call. + * + * @param int[] $echoareaIds + */ + public function bulkSetAreaSubscriptions(int $hubNodeId, array $echoareaIds): void + { + if (!$this->getById($hubNodeId)) { + throw new \InvalidArgumentException('Hub node not found'); + } + + $this->db->beginTransaction(); + try { + $this->db->prepare("DELETE FROM hub_node_areas WHERE hub_node_id = ?")->execute([$hubNodeId]); + + $stmt = $this->db->prepare("INSERT INTO hub_node_areas (hub_node_id, echoarea_id) VALUES (?, ?)"); + foreach (array_unique(array_map('intval', $echoareaIds)) as $echoareaId) { + $stmt->execute([$hubNodeId, $echoareaId]); + } + + $this->db->commit(); + } catch (\Throwable $e) { + $this->db->rollBack(); + throw $e; + } + } + + /** + * Active hub_nodes subscribed to a given echoarea (enabled, not held, + * subscription not individually paused). Used by HubFanout. + * + * @return array> + */ + public function getSubscribersForArea(int $echoareaId): array + { + $stmt = $this->db->prepare(" + SELECT " . self::COLUMNS . " + FROM hub_nodes hn + JOIN hub_node_areas hna ON hna.hub_node_id = hn.id + WHERE hna.echoarea_id = ? + AND hn.enabled = TRUE + AND hn.hold_mail = FALSE + AND hna.paused = FALSE + "); + $stmt->execute([$echoareaId]); + + return array_map([$this, 'normalizeRow'], $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []); + } + + /** + * IDs of the active echoareas a hub node is currently subscribed to and + * not paused on. Used by HubFanout::rescanForNode() for the AreaFix + * %RESCAN command - deliberately ignores hn.enabled/hold_mail (unlike + * getSubscribersForArea()) since rescanForNode() checks those itself. + * + * @return int[] + */ + public function getSubscribedEchoareaIds(int $hubNodeId): array + { + $stmt = $this->db->prepare(" + SELECT hna.echoarea_id + FROM hub_node_areas hna + JOIN echoareas ea ON ea.id = hna.echoarea_id + WHERE hna.hub_node_id = ? AND hna.paused = FALSE AND ea.is_active = TRUE + "); + $stmt->execute([$hubNodeId]); + + return array_map('intval', array_column($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [], 'echoarea_id')); + } + + /** + * The active, non-paused echoarea a hub node is currently subscribed to + * matching $tag, or null if it isn't subscribed to any such area. Used + * by HubAreafixProcessor's %RESCAN to scope a rescan to one + * area - deliberately requires an existing subscription rather than any + * matching area, so %RESCAN can't be used to peek at unsubscribed history. + * + * @return array{id: int, tag: string, domain: ?string}|null + */ + public function findSubscribedEchoareaByTag(int $hubNodeId, string $tag): ?array + { + $stmt = $this->db->prepare(" + SELECT ea.id, ea.tag, ea.domain + FROM hub_node_areas hna + JOIN echoareas ea ON ea.id = hna.echoarea_id + WHERE hna.hub_node_id = ? AND hna.paused = FALSE AND ea.is_active = TRUE + AND UPPER(ea.tag) = UPPER(?) + LIMIT 1 + "); + $stmt->execute([$hubNodeId, $tag]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return null; + } + + return ['id' => (int)$row['id'], 'tag' => $row['tag'], 'domain' => $row['domain']]; + } + + /** + * All file areas with the subscription status (and pause flag) for a given hub node. + * Mirrors getAreaSubscriptions(), including its domain scoping and the same + * always-include-existing-subscriptions rule. See docs/proposals/HubPointSystemJuly2026.md Phase 4. + * + * @return array> + */ + public function getFileAreaSubscriptions(int $hubNodeId): array + { + $hubNode = $this->getById($hubNodeId); + if (!$hubNode) { + throw new \InvalidArgumentException('Hub node not found'); + } + $domain = $this->resolveDomain($hubNode); + + $stmt = $this->db->prepare(" + SELECT fa.id AS file_area_id, fa.tag, fa.domain, fa.description, + hnf.id AS subscription_id, hnf.paused, hnf.subscribed_at + FROM file_areas fa + LEFT JOIN hub_node_fileareas hnf ON hnf.file_area_id = fa.id AND hnf.hub_node_id = ? + WHERE fa.is_active = TRUE + AND fa.is_private = FALSE + AND ( + hnf.id IS NOT NULL + OR (fa.domain IS NOT NULL AND fa.domain != '' AND LOWER(fa.domain) = LOWER(?)) + ) + ORDER BY fa.domain, fa.tag + "); + $stmt->execute([$hubNodeId, $domain]); + + return array_map(function (array $row) { + $row['file_area_id'] = (int)$row['file_area_id']; + $row['subscribed'] = $row['subscription_id'] !== null; + $row['paused'] = filter_var($row['paused'] ?? false, FILTER_VALIDATE_BOOLEAN); + return $row; + }, $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []); + } + + public function setFileAreaSubscription(int $hubNodeId, int $fileAreaId, bool $subscribed, bool $paused = false): void + { + if (!$this->getById($hubNodeId)) { + throw new \InvalidArgumentException('Hub node not found'); + } + + if (!$subscribed) { + $stmt = $this->db->prepare("DELETE FROM hub_node_fileareas WHERE hub_node_id = ? AND file_area_id = ?"); + $stmt->execute([$hubNodeId, $fileAreaId]); + return; + } + + $stmt = $this->db->prepare(" + INSERT INTO hub_node_fileareas (hub_node_id, file_area_id, paused) + VALUES (?, ?, ?) + ON CONFLICT (hub_node_id, file_area_id) DO UPDATE SET paused = EXCLUDED.paused + "); + $stmt->execute([$hubNodeId, $fileAreaId, $paused ? 'true' : 'false']); + } + + /** + * Replace the full file area subscription set for a hub node in one call. + * Mirrors bulkSetAreaSubscriptions(). + * + * @param int[] $fileAreaIds + */ + public function bulkSetFileAreaSubscriptions(int $hubNodeId, array $fileAreaIds): void + { + if (!$this->getById($hubNodeId)) { + throw new \InvalidArgumentException('Hub node not found'); + } + + $this->db->beginTransaction(); + try { + $this->db->prepare("DELETE FROM hub_node_fileareas WHERE hub_node_id = ?")->execute([$hubNodeId]); + + $stmt = $this->db->prepare("INSERT INTO hub_node_fileareas (hub_node_id, file_area_id) VALUES (?, ?)"); + foreach (array_unique(array_map('intval', $fileAreaIds)) as $fileAreaId) { + $stmt->execute([$hubNodeId, $fileAreaId]); + } + + $this->db->commit(); + } catch (\Throwable $e) { + $this->db->rollBack(); + throw $e; + } + } + + /** + * Active hub_nodes subscribed to a given file area (enabled, not held, + * subscription not individually paused). Used by HubFanout::fanoutFile(). + * + * @return array> + */ + public function getSubscribersForFileArea(int $fileAreaId): array + { + $stmt = $this->db->prepare(" + SELECT " . self::COLUMNS . " + FROM hub_nodes hn + JOIN hub_node_fileareas hnf ON hnf.hub_node_id = hn.id + WHERE hnf.file_area_id = ? + AND hn.enabled = TRUE + AND hn.hold_mail = FALSE + AND hnf.paused = FALSE + "); + $stmt->execute([$fileAreaId]); + + return array_map([$this, 'normalizeRow'], $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []); + } + + /** + * Suggest the next unused point number for a given boss AKA. + */ + public function suggestNextPointNumber(string $bossAddress): int + { + $stmt = $this->db->prepare(" + SELECT COALESCE(MAX(point_number), 0) AS max_point + FROM hub_nodes + WHERE node_type = 'point' AND boss_address = ? + "); + $stmt->execute([trim($bossAddress)]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return (int)($row['max_point'] ?? 0) + 1; + } + + /** + * The network domain a hub node/point belongs to, used to scope which + * echoareas/fileareas it may self-subscribe to via AreaFix/FileFix. For + * a point this is the domain of its boss AKA (points can only be + * created against one of our own configured AKAs, so this always + * resolves to exactly one domain); for a node it's resolved from its + * own address the same way BinkdProcessor resolves domain for inbound + * mail. Returns null if the domain can't be determined. + */ + public function resolveDomain(array $hubNode): ?string + { + $config = BinkpConfig::getInstance(); + + if (($hubNode['node_type'] ?? null) === self::TYPE_POINT) { + $bossAddress = trim((string)($hubNode['boss_address'] ?? '')); + foreach ($config->getMyAddressesWithDomains() as $entry) { + if ($entry['address'] === $bossAddress) { + return $entry['domain'] ?: null; + } + } + return null; + } + + $domain = $config->getDomainByAddress(trim((string)($hubNode['node_address'] ?? ''))); + return $domain !== false && $domain !== '' ? $domain : null; + } + + /** + * The AKAs BinktermPHP itself holds, for the boss-address picker. + * + * @return string[] + */ + public function getConfiguredAkas(): array + { + $config = BinkpConfig::getInstance(); + $akas = []; + + $systemAddress = $config->getSystemAddress(); + if (!empty($systemAddress)) { + $akas[] = $systemAddress; + } + + foreach ($config->getUplinks() as $uplink) { + $me = trim((string)($uplink['me'] ?? '')); + if ($me !== '') { + $akas[] = $me; + } + } + + return array_values(array_unique($akas)); + } + + /** + * @param array $data + * @return array + */ + private function prepareFields(array $data): array + { + $nodeType = (string)($data['node_type'] ?? self::TYPE_NODE); + if (!in_array($nodeType, [self::TYPE_NODE, self::TYPE_POINT], true)) { + throw new \InvalidArgumentException('Invalid node type'); + } + + if ($nodeType === self::TYPE_POINT) { + $bossAddress = trim((string)($data['boss_address'] ?? '')); + $pointNumber = (int)($data['point_number'] ?? 0); + if ($bossAddress === '' || $pointNumber <= 0) { + throw new \InvalidArgumentException('Boss address and point number are required for a point'); + } + if (!self::isValidFtnAddress($bossAddress) || EchomailSeenBy::isPointAddress($bossAddress)) { + throw new \InvalidArgumentException('Boss address must be one of our own AKAs in zone:net/node form, not itself a point'); + } + if (!in_array($bossAddress, $this->getConfiguredAkas(), true)) { + throw new \InvalidArgumentException('Boss address must be one of our own configured AKAs'); + } + $nodeAddress = $bossAddress . '.' . $pointNumber; + } else { + $bossAddress = null; + $pointNumber = null; + $nodeAddress = trim((string)($data['node_address'] ?? '')); + if ($nodeAddress === '' || !self::isValidFtnAddress($nodeAddress)) { + throw new \InvalidArgumentException('A valid zone:net/node[.point] node address is required'); + } + } + + return [ + 'node_type' => $nodeType, + 'node_address' => $nodeAddress, + 'boss_address' => $bossAddress, + 'point_number' => $pointNumber, + 'name' => trim((string)($data['name'] ?? '')) ?: null, + 'sysop_name' => trim((string)($data['sysop_name'] ?? '')) ?: null, + 'session_password' => (string)($data['session_password'] ?? '') ?: null, + 'packet_password' => (string)($data['packet_password'] ?? '') ?: null, + 'areafix_password' => (string)($data['areafix_password'] ?? '') ?: null, + 'filefix_password' => (string)($data['filefix_password'] ?? '') ?: null, + 'inet_host' => trim((string)($data['inet_host'] ?? '')) ?: null, + 'port' => !empty($data['port']) ? (int)$data['port'] : null, + 'enabled' => filter_var($data['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN), + 'allow_inbound' => filter_var($data['allow_inbound'] ?? true, FILTER_VALIDATE_BOOLEAN), + 'allow_outbound' => filter_var($data['allow_outbound'] ?? true, FILTER_VALIDATE_BOOLEAN), + 'allow_inbound_echomail' => filter_var($data['allow_inbound_echomail'] ?? true, FILTER_VALIDATE_BOOLEAN), + 'allow_inbound_netmail' => filter_var($data['allow_inbound_netmail'] ?? true, FILTER_VALIDATE_BOOLEAN), + 'max_packet_kb' => (int)($data['max_packet_kb'] ?? 0), + 'hold_mail' => filter_var($data['hold_mail'] ?? false, FILTER_VALIDATE_BOOLEAN), + 'compress_outbound' => filter_var($data['compress_outbound'] ?? false, FILTER_VALIDATE_BOOLEAN), + 'queue_retention_days' => (int)($data['queue_retention_days'] ?? 30), + 'capability_flags' => trim((string)($data['capability_flags'] ?? '')) ?: null, + 'notes' => trim((string)($data['notes'] ?? '')) ?: null, + ]; + } + + /** + * Validates zone:net/node[.point] form (e.g. "1:153/150" or "1:153/150.1"). + */ + public static function isValidFtnAddress(string $address): bool + { + return (bool)preg_match('/^\d+:\d+\/\d+(\.\d+)?$/', trim($address)); + } + + /** + * Convert prepareFields() output into PDO-bindable values (booleans as + * 'true'/'false' strings per PostgreSQL prepared-statement convention). + * + * @param array $prepared + * @return array + */ + private function bindable(array $prepared): array + { + foreach (['enabled', 'allow_inbound', 'allow_outbound', 'allow_inbound_echomail', 'allow_inbound_netmail', 'hold_mail', 'compress_outbound'] as $field) { + $prepared[$field] = $prepared[$field] ? 'true' : 'false'; + } + return $prepared; + } + + /** + * @param array $row + * @return array + */ + private function normalizeRow(array $row): array + { + $row['id'] = (int)$row['id']; + $row['point_number'] = $row['point_number'] !== null ? (int)$row['point_number'] : null; + $row['port'] = $row['port'] !== null ? (int)$row['port'] : null; + $row['max_packet_kb'] = (int)$row['max_packet_kb']; + $row['queue_retention_days'] = (int)$row['queue_retention_days']; + + foreach (['enabled', 'allow_inbound', 'allow_outbound', 'allow_inbound_echomail', 'allow_inbound_netmail', 'hold_mail', 'compress_outbound'] as $field) { + if (array_key_exists($field, $row)) { + $row[$field] = filter_var($row[$field], FILTER_VALIDATE_BOOLEAN); + } + } + + return $row; + } +} diff --git a/src/MessageHandler.php b/src/MessageHandler.php index 2bbea39e4..529fd02ef 100644 --- a/src/MessageHandler.php +++ b/src/MessageHandler.php @@ -1995,6 +1995,7 @@ public function postEchomail($fromUserId, $echoareaTag, $domain, $toName, $subje } $this->spoolOutboundEchomail($messageId, $echoareaTag, $domain); + $this->fanoutToHubNodes($messageId); } return $messageId > 0; @@ -2051,6 +2052,7 @@ public function approveEchomail(int $messageId): bool $domain = $message['echoarea_domain'] ?? ''; $this->spoolOutboundEchomail($messageId, $echoareaTag, $domain); + $this->fanoutToHubNodes($messageId); // Check whether the author should be auto-promoted $userId = $message['user_id'] ? (int)$message['user_id'] : null; @@ -3004,6 +3006,28 @@ private function spoolOutboundNetmail($messageId) $message['attributes'] |= 0x0800; } + // If the destination is a registered downlink node/point, deliver directly + // to it via hub_node_outbound instead of falling into uplink network-pattern + // routing, which has no knowledge of hub_nodes and would otherwise send this + // toward whatever uplink's pattern happens to match the destination's zone/net. + $hubRouter = new \BinktermPHP\Hub\HubNetmailRouter($this->db); + if ($hubRouter->routeOutboundIfHubNode($message, $messageId)) { + $this->db->prepare("UPDATE netmail SET is_sent = TRUE, spooled_at = CURRENT_TIMESTAMP WHERE id = ?") + ->execute([$messageId]); + + \BinktermPHP\Admin\AdminDaemonClient::log('INFO', 'netmail sent', [ + 'from' => "{$fromName} <{$fromAddr}>", + 'to' => "{$toName} <{$toAddr}>", + 'subject' => $subject, + 'msgid' => $message['message_id'] ?? '', + 'packet' => '(hub node outbound)', + ]); + + $this->queueImmediateOutboundPoll($toAddr, "netmail #{$messageId}"); + + return true; + } + // Get the uplink that handles routing for this destination // The packet must be addressed to the hub/uplink, not the final destination // The final destination is preserved in the message headers and INTL kludge @@ -3263,7 +3287,7 @@ private function sendFreqPickupNotification( } } - private function spoolOutboundEchomail($messageId, $echoareaTag, $domain) + public function spoolOutboundEchomail($messageId, $echoareaTag, $domain) { $stmt = $this->db->prepare(" SELECT em.*, ea.tag as echoarea_tag, ea.domain as echoarea_domain, ea.is_local @@ -3330,6 +3354,21 @@ private function spoolOutboundEchomail($messageId, $echoareaTag, $domain) if ($uplinkAddress) { $message['to_address'] = $uplinkAddress; + + // If this message already carries a PID kludge (i.e. it arrived + // via inbound packet processing, then is being relayed onward to + // our uplink), it also already has that author's tearline/origin + // embedded in message_text, and its own SEEN-BY/PATH already + // stored in bottom_kludges - suppress writeMessage()'s unconditional + // fresh PID+tearline+origin and single-hop SEEN-BY/PATH synthesis so + // we don't duplicate any of them; just pass the existing set through + // unmodified, same as a real tosser relaying traffic upward. Freshly + // locally-composed posts have no PID yet and should still get all of + // that generated fresh (this system is the originating tosser). + $isRelayed = strpos((string)($message['kludge_lines'] ?? ''), "\x01PID:") !== false; + $message['skip_default_pid_tearline'] = $isRelayed; + $message['skip_default_seenby_path'] = $isRelayed; + $packetFile = $binkdProcessor->createOutboundPacket([$message], $uplinkAddress); $packetName = basename($packetFile); $this->queueImmediateOutboundPoll($uplinkAddress, "echomail #{$messageId}"); @@ -3360,12 +3399,25 @@ private function spoolOutboundEchomail($messageId, $echoareaTag, $domain) } } + /** + * Fan a locally-approved echomail message out to subscribed hub_nodes + * (subordinate nodes/points). Failures are logged, never fatal to posting. + */ + private function fanoutToHubNodes(int $messageId): void + { + try { + (new \BinktermPHP\Hub\HubFanout())->fanout($messageId); + } catch (\Exception $e) { + $this->logger->error("[HUB] Fanout failed for echomail #{$messageId}: " . $e->getMessage()); + } + } + /** Returns an active uplink address for a given echoarea tag and domain. First choice is uplink in echoarea table, then to binkp.json configuration. * @param $echoareaTag - the tag, eg: LOCALTEST * @param $domain - the domain, eg: fidonet * @return false|mixed|string */ - private function getEchoareaUplink($echoareaTag, $domain='') + public function getEchoareaUplink($echoareaTag, $domain='') { // Uplinks require a domain - return false if domain is blank/null if (empty($domain)) { diff --git a/src/TicFileGenerator.php b/src/TicFileGenerator.php index 425299e4d..e229c0f5d 100644 --- a/src/TicFileGenerator.php +++ b/src/TicFileGenerator.php @@ -67,6 +67,16 @@ public function createTicFilesForUplinks(array $file, array $fileArea): array $createdTics = []; foreach ($uplinks as $uplink) { + // Loop guards for files that arrived via TIC from another system + // (docs/proposals/HubPointSystemJuly2026.md Phase 4 uplink relay): + // don't send a file straight back to the uplink we received it + // from, and don't send it to an uplink that already has a copy + // per the TIC Seenby trail. Locally-uploaded files have no + // uploaded_from_address/tic_seenby, so this is a no-op for them. + if ($this->uplinkAlreadyHasFile($file, $uplink)) { + continue; + } + try { $ticPath = $this->createTicFile($file, $fileArea, $uplink); if ($ticPath) { @@ -177,6 +187,66 @@ private function createTicFile(array $file, array $fileArea, array $uplink): ?st */ private function buildTicContent(array $file, array $fileArea, string $filename, string $fromAddress, string $toAddress, array $uplink): string { + // Password (FSC-87 Pw field) precedence: + // 1. file area TIC password + // 2. uplink TIC password + // If neither is set, emit a blank Pw field. + $password = $fileArea['password'] ?? ''; + if ($password === '') { + $password = $uplink['tic_password'] ?? ''; + } + + $lines = $this->buildTicLines($file, $fileArea, $filename, $fromAddress, $toAddress, [$fromAddress], [$fromAddress], $password); + + return implode("\r\n", $lines) . "\r\n"; + } + + /** + * Build TIC file content addressed to a hub-distribution downlink/point + * (docs/proposals/HubPointSystemJuly2026.md Phase 4), reusing the same + * FSC-87 field logic as the uplink path but with a caller-supplied + * Path/Seenby address set instead of the single-hop uplink defaults. + * + * @param array $file File record + * @param array $fileArea File area record + * @param string $filename Original filename (what the file should be named when received) + * @param string $fromAddress Our FidoNet address + * @param string $toAddress Destination downlink/point address + * @param string[] $pathAddresses Path hop addresses, in order + * @param string[] $seenByAddresses Seenby addresses that have already seen this file + * @param string $password TIC Pw field value (already resolved by the caller) + * @return string TIC file content + */ + public function buildTicContentForDownlink( + array $file, + array $fileArea, + string $filename, + string $fromAddress, + string $toAddress, + array $pathAddresses, + array $seenByAddresses, + string $password = '' + ): string { + $lines = $this->buildTicLines($file, $fileArea, $filename, $fromAddress, $toAddress, $pathAddresses, $seenByAddresses, $password); + + return implode("\r\n", $lines) . "\r\n"; + } + + /** + * @param string[] $pathAddresses + * @param string[] $seenByAddresses + * @return string[] TIC content lines, without trailing newline + */ + private function buildTicLines( + array $file, + array $fileArea, + string $filename, + string $fromAddress, + string $toAddress, + array $pathAddresses, + array $seenByAddresses, + string $password + ): array { $lines = []; // Area tag (required by FSC-87) @@ -224,27 +294,22 @@ private function buildTicContent(array $file, array $fileArea, string $filename, $crc = $this->calculateCrc32($file['storage_path']); $lines[] = 'Crc ' . strtoupper(dechex($crc)); - // Path line (shows routing path) - $lines[] = 'Path ' . $fromAddress; - - // Seenby (required by FSC-87) - at least our address - $lines[] = 'Seenby ' . $fromAddress; + // Path line(s) (shows routing path) - one line per hop, FSC-87 allows repetition + foreach ($pathAddresses as $pathAddress) { + $lines[] = 'Path ' . $pathAddress; + } - // Password (FSC-87 Pw field) precedence: - // 1. file area TIC password - // 2. uplink TIC password - // If neither is set, emit a blank Pw field. - $password = $fileArea['password'] ?? ''; - if ($password === '') { - $password = $uplink['tic_password'] ?? ''; + // Seenby line(s) (required by FSC-87) - at least our address + foreach ($seenByAddresses as $seenByAddress) { + $lines[] = 'Seenby ' . $seenByAddress; } + $lines[] = 'Pw ' . $password; // Created by $lines[] = 'Created BinktermPHP ' . \BinktermPHP\Version::getVersion(); - // Add final newline - return implode("\r\n", $lines) . "\r\n"; + return $lines; } /** @@ -269,5 +334,39 @@ private function getUplinksForDomain(string $domain): array { return $this->config->getUplinksForDomain($domain); } + + /** + * True if $uplink is the system this file was received from, or already + * appears in the file's TIC Seenby trail. + * + * @param array $file File record from database (uploaded_from_address, tic_seenby) + * @param array $uplink Uplink configuration (address) + */ + private function uplinkAlreadyHasFile(array $file, array $uplink): bool + { + $uplinkAddress = trim((string)($uplink['address'] ?? '')); + if ($uplinkAddress === '') { + return false; + } + $uplinkParts = \BinktermPHP\Echomail\EchomailSeenBy::parseFtnAddressParts($uplinkAddress); + + $fromAddress = trim((string)($file['uploaded_from_address'] ?? '')); + if ($fromAddress !== '') { + $fromParts = \BinktermPHP\Echomail\EchomailSeenBy::parseFtnAddressParts($fromAddress); + if ($fromParts['net'] === $uplinkParts['net'] && $fromParts['node'] === $uplinkParts['node']) { + return true; + } + } + + $seenBy = preg_split('/\s+/', trim((string)($file['tic_seenby'] ?? '')), -1, PREG_SPLIT_NO_EMPTY) ?: []; + foreach ($seenBy as $seenByAddress) { + $seenByParts = \BinktermPHP\Echomail\EchomailSeenBy::parseFtnAddressParts($seenByAddress); + if ($seenByParts['net'] === $uplinkParts['net'] && $seenByParts['node'] === $uplinkParts['node']) { + return true; + } + } + + return false; + } } diff --git a/src/functions.php b/src/functions.php index 3dbe5d5e8..29fd4ad4b 100644 --- a/src/functions.php +++ b/src/functions.php @@ -403,3 +403,18 @@ function generateTzutc($timezone = null) { } } +/** + * Build a complete FTS-4009 Via kludge line (SOH-prefixed, matching how + * PATH lines and every other FTN control line are stored/written elsewhere + * in this codebase) recording a relay hop: our own address, the current + * UTC time, and product/version - mirrors the PID kludge's program/version + * fields. Used whenever BinktermPHP relays a message it did not originate + * to a registered downlink/uplink, so the path a message took through the + * network stays visible (e.g. "Via 227:1/1 @20260808.023905.UTC hpt/lnx 1.9 + * 2024-03-02" is the same convention a remote binkd/tosser uses). + */ +function generateViaLine(string $address): string +{ + return "\x01Via {$address} @" . gmdate('Ymd.His') . '.UTC BinktermPHP ' . \BinktermPHP\Version::getVersion(); +} + diff --git a/templates/admin/hub_nodes.twig b/templates/admin/hub_nodes.twig new file mode 100644 index 000000000..e6d526a5d --- /dev/null +++ b/templates/admin/hub_nodes.twig @@ -0,0 +1,580 @@ +{% extends "base.twig" %} + +{% block title %}{{ t('ui.admin.hub_nodes.page_title', {}, locale, ['common']) }}{% endblock %} + +{% block content %} +
+
+

{{ t('ui.admin.hub_nodes.heading', {}, locale, ['common']) }}

+ +
+ +

{{ t('ui.admin.hub_nodes.intro', {}, locale, ['common']) }}

+ +
+ +
+
+
+ + + + + + + + + + + + +
+ + {{ t('ui.admin.hub_nodes.address', {}, locale, ['common']) }}{{ t('ui.admin.hub_nodes.name', {}, locale, ['common']) }}{{ t('ui.admin.hub_nodes.flags', {}, locale, ['common']) }}{{ t('ui.admin.hub_nodes.queue', {}, locale, ['common']) }}{{ t('ui.common.actions', {}, locale, ['common']) }}
+
+
+
+
+ + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/base.twig b/templates/base.twig index ec2c57f6f..0aa500956 100644 --- a/templates/base.twig +++ b/templates/base.twig @@ -391,8 +391,9 @@