From 36c4ab8dc5432a85ae9fcad0d07472148920837f Mon Sep 17 00:00:00 2001 From: awehttam Date: Fri, 7 Aug 2026 18:00:30 -0700 Subject: [PATCH 01/25] Add Phase 1 of hub/downlink echomail distribution system Lets BinktermPHP distribute echomail to subordinate FTN nodes and points (boss.point addresses), not just receive from uplinks. Adds hub_nodes/hub_node_areas/hub_node_outbound tables, HubNodeManager (CRUD + area subscriptions), HubFanout (SEEN-BY/PATH-aware fanout into the outbound queue), and an admin UI at /admin/hub-nodes (labeled "Downlinks" to avoid confusion with upstream uplinks). This is enqueue-only per docs/proposals/HubPointSystemJuly2026.md Phase 1 - actual binkp delivery of queued packets is Phase 2. Co-Authored-By: Claude Sonnet 5 --- config/i18n/de/common.php | 43 ++ config/i18n/de/errors.php | 6 + config/i18n/en/common.php | 43 ++ config/i18n/en/errors.php | 6 + config/i18n/es/common.php | 43 ++ config/i18n/es/errors.php | 6 + config/i18n/fr/common.php | 43 ++ config/i18n/fr/errors.php | 6 + config/i18n/it/common.php | 43 ++ config/i18n/it/errors.php | 6 + config/i18n/ru/common.php | 43 ++ config/i18n/ru/errors.php | 6 + ...v20260808003922_create_hub_nodes_table.sql | 41 ++ ...0808003926_create_hub_node_areas_table.sql | 15 + ...8003927_create_hub_node_outbound_table.sql | 31 ++ docs/proposals/HubPointSystemJuly2026.md | 478 ++++++++++++++++++ routes/admin-routes.php | 141 ++++++ src/BinkdProcessor.php | 14 +- src/Echomail/EchomailSeenBy.php | 224 ++++++++ src/Hub/HubFanout.php | 158 ++++++ src/Hub/HubNodeManager.php | 385 ++++++++++++++ src/MessageHandler.php | 15 + templates/admin/hub_nodes.twig | 467 +++++++++++++++++ templates/base.twig | 1 + templates/shells/bbs-menu/base.twig | 1 + templates/shells/web/base.twig | 1 + 26 files changed, 2264 insertions(+), 2 deletions(-) create mode 100644 database/migrations/v20260808003922_create_hub_nodes_table.sql create mode 100644 database/migrations/v20260808003926_create_hub_node_areas_table.sql create mode 100644 database/migrations/v20260808003927_create_hub_node_outbound_table.sql create mode 100644 docs/proposals/HubPointSystemJuly2026.md create mode 100644 src/Echomail/EchomailSeenBy.php create mode 100644 src/Hub/HubFanout.php create mode 100644 src/Hub/HubNodeManager.php create mode 100644 templates/admin/hub_nodes.twig diff --git a/config/i18n/de/common.php b/config/i18n/de/common.php index 174339044..c1b81fbe7 100644 --- a/config/i18n/de/common.php +++ b/config/i18n/de/common.php @@ -5342,4 +5342,47 @@ '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' => '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.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.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', ]; diff --git a/config/i18n/de/errors.php b/config/i18n/de/errors.php index ea10624a7..d6ecdd4d8 100644 --- a/config/i18n/de/errors.php +++ b/config/i18n/de/errors.php @@ -769,4 +769,10 @@ '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.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..d6f1f0c36 100644 --- a/config/i18n/en/common.php +++ b/config/i18n/en/common.php @@ -5235,6 +5235,49 @@ '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' => '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.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.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.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..d3ee0ca67 100644 --- a/config/i18n/en/errors.php +++ b/config/i18n/en/errors.php @@ -747,6 +747,12 @@ '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.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..3e57bc015 100644 --- a/config/i18n/es/common.php +++ b/config/i18n/es/common.php @@ -5330,4 +5330,47 @@ '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', + '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.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.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', ]; diff --git a/config/i18n/es/errors.php b/config/i18n/es/errors.php index 2b3928efb..610bcd9f5 100644 --- a/config/i18n/es/errors.php +++ b/config/i18n/es/errors.php @@ -767,4 +767,10 @@ '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.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..ce19b5e57 100644 --- a/config/i18n/fr/common.php +++ b/config/i18n/fr/common.php @@ -5237,4 +5237,47 @@ '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', + '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.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.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', ]; diff --git a/config/i18n/fr/errors.php b/config/i18n/fr/errors.php index f169f7f33..8d7eae9f9 100644 --- a/config/i18n/fr/errors.php +++ b/config/i18n/fr/errors.php @@ -722,6 +722,12 @@ '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.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..a4ae1836a 100644 --- a/config/i18n/it/common.php +++ b/config/i18n/it/common.php @@ -5327,4 +5327,47 @@ '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', + '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.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.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', ]; diff --git a/config/i18n/it/errors.php b/config/i18n/it/errors.php index 281289022..bd73ee477 100644 --- a/config/i18n/it/errors.php +++ b/config/i18n/it/errors.php @@ -767,4 +767,10 @@ '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.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..e6eb86a1a 100644 --- a/config/i18n/ru/common.php +++ b/config/i18n/ru/common.php @@ -5364,4 +5364,47 @@ 'ui.settings.pgp.upload_success' => 'PGP-ключ сохранён.', 'ui.settings.pgp.uploading' => 'Загрузка открытого ключа...', 'ui.settings.tab.pgp' => 'PGP', + 'ui.base.admin.hub_nodes' => 'Даунлинки', + '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.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.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' => 'Подписки сохранены', ]; diff --git a/config/i18n/ru/errors.php b/config/i18n/ru/errors.php index b8ae2bee3..23948abbf 100644 --- a/config/i18n/ru/errors.php +++ b/config/i18n/ru/errors.php @@ -770,4 +770,10 @@ '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.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/docs/proposals/HubPointSystemJuly2026.md b/docs/proposals/HubPointSystemJuly2026.md new file mode 100644 index 000000000..c26e40911 --- /dev/null +++ b/docs/proposals/HubPointSystemJuly2026.md @@ -0,0 +1,478 @@ +# 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 and route netmail 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`, not yet merged or committed. Verified end-to-end against a dev database (node + point subscriber, fanout, SEEN-BY/PATH merge, no delivery). + +**Phase 2 (Delivery): Not started.** Messages currently queue in `hub_node_outbound` with `status='pending'` and are never sent — there is no binkp server/session support for hub subordinates yet, and no push mechanism. + +**Phase 3 (Netmail routing): Not started.** + +**Phase 4 (Areafix, server-side): Not started.** + +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) +- [Areafix (Future Phase)](#areafix-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 +- Areafix 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) + 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`. + +--- + +## 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 + +--- + +## Areafix (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). 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 commands sent to us by our own subordinates. + +### Commands (standard) + +| Command | Effect | +|---|---| +| `+GENERAL` | Subscribe to GENERAL | +| `-COOKING` | Unsubscribe from COOKING | +| `%LIST` | Reply with available areas | +| `%QUERY` | Reply with currently subscribed areas | +| `%HELP` | Reply with command reference | +| `%PAUSE` | Pause all areas (hold mail) | +| `%RESUME` | Resume all areas | + +### Design notes for current phase + +- The `hub_node_areas.paused` and `hub_nodes.hold_mail` columns support Areafix PAUSE/RESUME already +- When adding area subscriptions via admin UI, the same `hub_node_areas` table is used — Areafix will just be an automated writer to the same table +- The Areafix robot applies identically to nodes and points; it is implemented as a `PacketProcessor` hook that intercepts netmails addressed to a configurable robot name and dispatches commands, resolving the sender against `hub_nodes.node_address` + +--- + +## 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`, uncommitted) + +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 — **Not started** + +6. [ ] Binkp server: authenticate subordinates from `hub_nodes`; serve `hub_node_outbound` +7. [ ] Push flags (`--hub-node` / `--all-hub-nodes`) for node-type subordinates with a routable host +8. [ ] Scheduler integration for push delivery + +### Phase 3 — Netmail routing — **Not started** + +9. [ ] Netmail passthrough routing to nodes and points, gated by `allow_inbound_netmail` and `HUB_ROUTE_NETMAIL` + +### Phase 4 — Areafix (separate proposal) — **Not started** + +10. [ ] Areafix robot and netmail command parser (server-side) +11. [ ] Reply packet generation +12. [ ] Per-subordinate access controls (which areas they're allowed to request) + +--- + +## 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` | Packet assembly and outbound queue management | Not started (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) | + +## 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). | Done (Phase 1, echomail only — netmail routing check is Phase 3) | +| Binkp server session handling (`src/Binkp/Protocol/BinkpSession.php`) | Authenticate subordinates; serve `hub_node_outbound` during pull sessions | Not started (Phase 2) | +| Binkp poll entry point | Add `--hub-node` / `--all-hub-nodes` flags for push delivery | Not started (Phase 2) | +| `src/Binkp/Connection/Scheduler.php` | Add hub node push schedules | Not started (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) | Done (Phase 1) | +| `templates/base.twig`, `templates/shells/web/base.twig`, `templates/shells/bbs-menu/base.twig` | Added "Hub Nodes" nav entry next to "Networks" | Done (Phase 1) | +| `config/i18n/{de,en,es,fr,it,ru}/{common,errors}.php` | Added `ui.admin.hub_nodes.*` / `errors.admin.hub_nodes.*` keys | Done (Phase 1) | + +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). + +--- + +## 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 +- `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 — Phase 1 implemented (branch `hubpoint`, uncommitted); Phases 2-4 not started +**Last Updated:** 2026-08-08 +**Author:** AI-Generated (Requires Review) diff --git a/routes/admin-routes.php b/routes/admin-routes.php index 4f83227b1..9d47d7ee6 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,140 @@ 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/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/src/BinkdProcessor.php b/src/BinkdProcessor.php index b5b4a3aa1..22be9fad8 100644 --- a/src/BinkdProcessor.php +++ b/src/BinkdProcessor.php @@ -1531,6 +1531,12 @@ 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()); + } } //$this->log("[BINKD] Stored echomail in echoarea id ".$echoarea['id']." from=".$fromAddress." messageId=".$messageId." subject=".$message['subject']); @@ -2101,8 +2107,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 diff --git a/src/Echomail/EchomailSeenBy.php b/src/Echomail/EchomailSeenBy.php new file mode 100644 index 000000000..7f1ba69aa --- /dev/null +++ b/src/Echomail/EchomailSeenBy.php @@ -0,0 +1,224 @@ + 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; + } + + /** + * 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/Hub/HubFanout.php b/src/Hub/HubFanout.php new file mode 100644 index 000000000..be77eaa8e --- /dev/null +++ b/src/Hub/HubFanout.php @@ -0,0 +1,158 @@ +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. + $bottomKludges = trim((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)); + } + + $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, + ]; + + $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, size_bytes, status) + VALUES (:hub_node_id, 'echomail', :echoarea_id, :echomail_id, :packet_data, :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(':size_bytes', strlen($bytes), PDO::PARAM_INT); + $stmt->execute(); + } + + private function tempPacketPath(): string + { + return sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'hubfanout_' . uniqid('', true) . '.pkt'; + } +} diff --git a/src/Hub/HubNodeManager.php b/src/Hub/HubNodeManager.php new file mode 100644 index 000000000..55258c109 --- /dev/null +++ b/src/Hub/HubNodeManager.php @@ -0,0 +1,385 @@ +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, inet_host, port, enabled, allow_inbound, + allow_outbound, allow_inbound_echomail, allow_inbound_netmail, max_packet_kb, + hold_mail, queue_retention_days, capability_flags, notes + ) VALUES ( + :node_type, :node_address, :boss_address, :point_number, :name, :sysop_name, + :session_password, :packet_password, :inet_host, :port, :enabled, :allow_inbound, + :allow_outbound, :allow_inbound_echomail, :allow_inbound_netmail, :max_packet_kb, + :hold_mail, :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, + 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, + 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. + * + * @return array> + */ + public function getAreaSubscriptions(int $hubNodeId): array + { + $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 + ORDER BY ea.domain, ea.tag + "); + $stmt->execute([$hubNodeId]); + + 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) ?: []); + } + + /** + * 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 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, + '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), + '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'] 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'] 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..709d22c66 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; @@ -3360,6 +3362,19 @@ 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 diff --git a/templates/admin/hub_nodes.twig b/templates/admin/hub_nodes.twig new file mode 100644 index 000000000..4e6bc936e --- /dev/null +++ b/templates/admin/hub_nodes.twig @@ -0,0 +1,467 @@ +{% 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..1e7915801 100644 --- a/templates/base.twig +++ b/templates/base.twig @@ -393,6 +393,7 @@
  • {{ t('ui.base.admin.ai_settings', {}, 'common') }}
  • {{ t('ui.base.admin.binkp_configuration', {}, 'common') }}
  • {{ t('ui.base.admin.networks', {}, 'common') }}
  • +
  • {{ t('ui.base.admin.hub_nodes', {}, 'common') }}
  • {{ t('ui.base.admin.packet_bbs', {}, 'common') }}
  • {{ t('ui.base.admin.i18n_overrides', {}, 'common') }}
  • {{ t('ui.base.admin.licensing', {}, 'common') }}
  • diff --git a/templates/shells/bbs-menu/base.twig b/templates/shells/bbs-menu/base.twig index 484d886b4..231aff420 100644 --- a/templates/shells/bbs-menu/base.twig +++ b/templates/shells/bbs-menu/base.twig @@ -248,6 +248,7 @@
  • {{ t('ui.base.admin.bbs_settings', {}, 'common') }}
  • {{ t('ui.base.admin.binkp_configuration', {}, 'common') }}
  • {{ t('ui.base.admin.networks', {}, 'common') }}
  • +
  • {{ t('ui.base.admin.hub_nodes', {}, 'common') }}
  • {{ t('ui.base.admin.i18n_overrides', {}, 'common') }}
  • {{ t('ui.base.admin.template_editor', {}, 'common') }}
  • diff --git a/templates/shells/web/base.twig b/templates/shells/web/base.twig index 575dd8e28..557cda51d 100644 --- a/templates/shells/web/base.twig +++ b/templates/shells/web/base.twig @@ -441,6 +441,7 @@
  • {{ t('ui.base.admin.bbs_settings', {}, 'common') }}
  • {{ t('ui.base.admin.binkp_configuration', {}, 'common') }}
  • {{ t('ui.base.admin.networks', {}, 'common') }}
  • +
  • {{ t('ui.base.admin.hub_nodes', {}, 'common') }}
  • {{ t('ui.base.admin.packet_bbs', {}, 'common') }}
  • {{ t('ui.base.admin.i18n_overrides', {}, 'common') }}
  • {{ t('ui.base.admin.licensing', {}, 'common') }}
  • From 7ad64d70fc9d07b20b0ccbb313a56b3c909eca8f Mon Sep 17 00:00:00 2001 From: awehttam Date: Fri, 7 Aug 2026 18:16:59 -0700 Subject: [PATCH 02/25] Add Phase 2 of hub/downlink echomail distribution system: delivery Delivers the packets Phase 1 enqueued into hub_node_outbound, both pull and push: - BinkpSession: M_ADR matching and password lookup now also check hub_nodes (fixes a real bug where a multi-AKA peer could get the wrong address selected as remoteAddress). New sendHubNodeOutbound() serves pending hub_node_outbound rows to the authenticated peer, mirroring the existing sendFreqFiles()/sendHoldFiles() pattern. - BinkpClient: connect()'s existing non-uplink fallback now checks hub_nodes (inet_host/port/session_password) before nodelist/DNS, so push delivery works through existing address-based call paths with no protocol/IPC changes. New pollAllHubNodes(). - binkp_poll.php: new --all-hub-nodes flag; a plain positional address already resolves hub nodes automatically via the connect() change. - Scheduler: new runScheduledHubNodePush(), modeled on the existing crashmail-poll pattern, wired into the daemon loop. Verified live end-to-end: a raw CRAM-MD5 binkp client simulating a hub node authenticated against hub_nodes and received its queued packet over the wire from a real binkp_server.php instance. Co-Authored-By: Claude Sonnet 5 --- docs/proposals/HubPointSystemJuly2026.md | 29 ++++---- scripts/binkp_poll.php | 29 +++++++- src/Binkp/Connection/Scheduler.php | 56 +++++++++++++++ src/Binkp/Protocol/BinkpClient.php | 67 +++++++++++++++--- src/Binkp/Protocol/BinkpSession.php | 88 ++++++++++++++++++++++-- 5 files changed, 238 insertions(+), 31 deletions(-) diff --git a/docs/proposals/HubPointSystemJuly2026.md b/docs/proposals/HubPointSystemJuly2026.md index c26e40911..68046d088 100644 --- a/docs/proposals/HubPointSystemJuly2026.md +++ b/docs/proposals/HubPointSystemJuly2026.md @@ -27,9 +27,9 @@ Those two documents can be treated as historical background; new implementation ## Implementation Status -**Phase 1 (Core Infrastructure): Implemented**, on branch `hubpoint`, not yet merged or committed. Verified end-to-end against a dev database (node + point subscriber, fanout, SEEN-BY/PATH merge, no delivery). +**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): Not started.** Messages currently queue in `hub_node_outbound` with `status='pending'` and are never sent — there is no binkp server/session support for hub subordinates yet, and no push mechanism. +**Phase 2 (Delivery): Implemented**, on branch `hubpoint`, not yet committed. 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): Not started.** @@ -383,7 +383,7 @@ Areafix is a netmail robot that allows remote sysops (or point owners) to manage ## Implementation Plan -### Phase 1 — Core infrastructure — **Done** (branch `hubpoint`, uncommitted) +### 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 @@ -391,11 +391,11 @@ Areafix is a netmail robot that allows remote sysops (or point owners) to manage 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 — **Not started** +### Phase 2 — Delivery — **Done** (branch `hubpoint`, uncommitted) -6. [ ] Binkp server: authenticate subordinates from `hub_nodes`; serve `hub_node_outbound` -7. [ ] Push flags (`--hub-node` / `--all-hub-nodes`) for node-type subordinates with a routable host -8. [ ] Scheduler integration for push delivery +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 — **Not started** @@ -415,7 +415,7 @@ Areafix is a netmail robot that allows remote sysops (or point owners) to manage |---|---|---| | `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` | Packet assembly and outbound queue management | Not started (Phase 2) | +| ~~`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) | @@ -429,13 +429,14 @@ Areafix is a netmail robot that allows remote sysops (or point owners) to manage | 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). | Done (Phase 1, echomail only — netmail routing check is Phase 3) | -| Binkp server session handling (`src/Binkp/Protocol/BinkpSession.php`) | Authenticate subordinates; serve `hub_node_outbound` during pull sessions | Not started (Phase 2) | -| Binkp poll entry point | Add `--hub-node` / `--all-hub-nodes` flags for push delivery | Not started (Phase 2) | -| `src/Binkp/Connection/Scheduler.php` | Add hub node push schedules | Not started (Phase 2) | +| `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) | Done (Phase 1) | -| `templates/base.twig`, `templates/shells/web/base.twig`, `templates/shells/bbs-menu/base.twig` | Added "Hub Nodes" nav entry next to "Networks" | Done (Phase 1) | -| `config/i18n/{de,en,es,fr,it,ru}/{common,errors}.php` | Added `ui.admin.hub_nodes.*` / `errors.admin.hub_nodes.*` keys | Done (Phase 1) | +| `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) | 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`.) @@ -473,6 +474,6 @@ Exact packet-processing entry point and outbound file layout should be confirmed --- -**Document Status:** Draft Proposal — Phase 1 implemented (branch `hubpoint`, uncommitted); Phases 2-4 not started +**Document Status:** Draft Proposal — Phases 1-2 implemented (branch `hubpoint`; Phase 1 committed `36c4ab8d`, Phase 2 uncommitted); Phases 3-4 not started **Last Updated:** 2026-08-08 **Author:** AI-Generated (Requires Review) 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/src/Binkp/Connection/Scheduler.php b/src/Binkp/Connection/Scheduler.php index 315d01dcb..96d07538c 100644 --- a/src/Binkp/Connection/Scheduler.php +++ b/src/Binkp/Connection/Scheduler.php @@ -47,6 +47,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 +524,7 @@ public function runDaemon($interval = 60) $this->processInboundIfNeeded(); $this->runScheduledCrashmailPoll(); + $this->runScheduledHubNodePush(); $this->processAdvertisingCampaigns(); } catch (\Exception $e) { @@ -574,6 +579,57 @@ 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(); + $stmt = $db->query(" + 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' + 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 + "); + $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..f4e78d700 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,74 @@ private function sendHoldFiles(): void } } + /** + * Send any pending hub_node_outbound packets queued for the connecting + * subordinate (node or point). Called at session start for both + * originator and answerer, mirroring sendFreqFiles()/sendHoldFiles(). + */ + 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(); + $stmt = $db->prepare( + "SELECT id, packet_data + FROM hub_node_outbound + WHERE hub_node_id = ? AND status = 'pending' + ORDER BY priority ASC, next_attempt_at ASC" + ); + $stmt->execute([$hubNode['id']]); + $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 = ?" + ); + + foreach ($rows as $row) { + $id = (int)$row['id']; + $bytes = $row['packet_data']; + if (is_resource($bytes)) { + $bytes = stream_get_contents($bytes); + } + + $tmpPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'hubnode_' . uniqid('', true) . '.pkt'; + try { + if (file_put_contents($tmpPath, $bytes) === false) { + throw new \Exception('Failed to write temp packet file'); + } + $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 { + @unlink($tmpPath); + } + } + } catch (\Exception $e) { + $this->log("sendHubNodeOutbound error: " . $e->getMessage(), 'ERROR'); + } + } + private function sendFiles() { $outboundPath = $this->config->getOutboundPath(); @@ -2049,10 +2121,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 ''; } From 0e4c6a7fb9321ca6b603a03c4d8d68db6f6da674 Mon Sep 17 00:00:00 2001 From: awehttam Date: Fri, 7 Aug 2026 19:17:21 -0700 Subject: [PATCH 03/25] Add Phase 3 netmail routing and fix outbound netmail to downlinks Inbound: netmail addressed to a registered hub node/point (not to us) is now forwarded into hub_node_outbound as transit mail instead of being dropped as undeliverable, gated behind HUB_ROUTE_NETMAIL (off by default). Outbound: fixes a real bug where netmail composed by a local user and addressed to a registered downlink (e.g. 227:1/400.1) got misrouted through an unrelated uplink's network-pattern wildcard match instead of going to the downlink directly. Co-Authored-By: Claude Sonnet 5 --- docs/CONFIGURATION.md | 6 + docs/proposals/HubPointSystemJuly2026.md | 20 ++- src/BinkdProcessor.php | 7 + src/Hub/HubNetmailRouter.php | 170 +++++++++++++++++++++++ src/MessageHandler.php | 22 +++ 5 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 src/Hub/HubNetmailRouter.php diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 873b112b6..8c1f452da 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -267,6 +267,12 @@ PERF_LOG_SLOW_MS=500 # File area rule action log FILEAREA_RULE_ACTION_LOG=data/logs/filearea_rules.log +# Route inbound netmail addressed to a registered hub node/point (see +# Admin -> Downlinks) into its outbound queue instead of dropping it as +# undeliverable. Also requires the hub node's own "Accept Netmail From +# This Subordinate" setting to be enabled. +# HUB_ROUTE_NETMAIL=false + # ⚠️ DEVELOPMENT MODE — NEVER enable on a production system. # Activates destructive diagnostic functions that can disrupt normal operation, # including the ability to purge per-user QWK state (conference pointers, diff --git a/docs/proposals/HubPointSystemJuly2026.md b/docs/proposals/HubPointSystemJuly2026.md index 68046d088..23c812807 100644 --- a/docs/proposals/HubPointSystemJuly2026.md +++ b/docs/proposals/HubPointSystemJuly2026.md @@ -29,9 +29,13 @@ Those two documents can be treated as historical background; new implementation **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`, not yet committed. 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 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): Not started.** +**Phase 3 (Netmail routing): Implemented**, on branch `hubpoint`, not yet committed. Two directions are covered: +- **Inbound transit** — 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. Gated off by default behind the `HUB_ROUTE_NETMAIL` env flag (see `docs/CONFIGURATION.md`) since it changes handling of mail not addressed to us. +- **Outbound to a registered downlink** — added after initial ship, in response to a real bug report: 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`. **Not** gated by `HUB_ROUTE_NETMAIL` (that flag is specifically about relaying foreign transit mail; this is our own user mailing an address we administer — the per-node `allow_outbound` flag is the opt-in). Verified end-to-end: packet header now correctly shows the downlink's boss address instead of the wrong uplink. + +**Remaining scope boundary**: **file-attach netmail** (`FILE_ATTACH` bit) routed to a hub node — either 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, and wasn't addressed for the outbound case either. Noted as a known limitation, not solved in Phase 3. **Phase 4 (Areafix, server-side): Not started.** @@ -397,9 +401,9 @@ Areafix is a netmail robot that allows remote sysops (or point owners) to manage 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 — **Not started** +### Phase 3 — Netmail routing — **Done** (branch `hubpoint`, uncommitted; inbound transit only — see Implementation Status scope boundaries) -9. [ ] Netmail passthrough routing to nodes and points, gated by `allow_inbound_netmail` and `HUB_ROUTE_NETMAIL` +9. [x] Netmail passthrough routing to nodes and points, gated by `allow_inbound_netmail` and `HUB_ROUTE_NETMAIL` — `src/Hub/HubNetmailRouter.php` ### Phase 4 — Areafix (separate proposal) — **Not started** @@ -423,20 +427,22 @@ Areafix is a netmail robot that allows remote sysops (or point owners) to manage | `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 `HUB_ROUTE_NETMAIL` and per-node `allow_inbound_netmail`. `routeOutboundIfHubNode()`: locally-composed netmail addressed to a registered downlink, gated by per-node `allow_outbound` only. Both share a private `buildAndEnqueue()` helper for packet build + insert. | Done (Phase 3) | ## 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). | Done (Phase 1, echomail only — netmail routing check is Phase 3) | +| `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) | 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 | Done (Phase 3) | 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`.) @@ -474,6 +480,6 @@ Exact packet-processing entry point and outbound file layout should be confirmed --- -**Document Status:** Draft Proposal — Phases 1-2 implemented (branch `hubpoint`; Phase 1 committed `36c4ab8d`, Phase 2 uncommitted); Phases 3-4 not started +**Document Status:** Draft Proposal — Phases 1-3 implemented (branch `hubpoint`; Phase 1 committed `36c4ab8d`, Phase 2 committed `7ad64d70`, Phase 3 uncommitted); Phase 4 not started **Last Updated:** 2026-08-08 **Author:** AI-Generated (Requires Review) diff --git a/src/BinkdProcessor.php b/src/BinkdProcessor.php index 22be9fad8..659c507ba 100644 --- a/src/BinkdProcessor.php +++ b/src/BinkdProcessor.php @@ -951,6 +951,13 @@ private function hasAreaKludgeLine($messageText) private function storeNetmail($message, $packetInfo = null, $isInsecureSession = false, bool &$undeliverable = false) { + // 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) { diff --git a/src/Hub/HubNetmailRouter.php b/src/Hub/HubNetmailRouter.php new file mode 100644 index 000000000..c9e06be16 --- /dev/null +++ b/src/Hub/HubNetmailRouter.php @@ -0,0 +1,170 @@ +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. + * + * @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 + { + if (Config::env('HUB_ROUTE_NETMAIL', 'false') !== 'true') { + return false; + } + + $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] = $this->splitKludges((string)($message['text'] ?? '')); + + $dateWritten = null; + $parsed = strtotime((string)($message['dateTime'] ?? '')); + if ($parsed !== false) { + $dateWritten = date('Y-m-d H:i:s', $parsed); + } + + $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, + 'date_written' => $dateWritten, + 'attributes' => $message['attributes'] ?? 0, + 'is_echomail' => 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; + + return $this->buildAndEnqueue($packetMessage, $hubNode, $netmailId); + } + + /** + * Build a .pkt from $packetMessage addressed to $hubNode and insert it + * into hub_node_outbound. Shared by both routing directions. + * + * @param array $hubNode + */ + private 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, size_bytes, status) + VALUES (:hub_node_id, 'netmail', :netmail_id, :packet_data, :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(':size_bytes', strlen($bytes), PDO::PARAM_INT); + $stmt->execute(); + + return true; + } + + /** + * Split raw FTN message text into [body, kludges], preserving original + * kludge lines (MSGID, INTL, etc.) verbatim for relay rather than + * regenerating them. + * + * @return array{0:string,1:string} + */ + private function splitKludges(string $text): array + { + $lines = preg_split('/\r\n|\r|\n/', $text) ?: []; + $body = []; + $kludges = []; + + foreach ($lines as $line) { + if (strlen($line) > 0 && ord($line[0]) === 0x01) { + $kludges[] = $line; + } else { + $body[] = $line; + } + } + + return [implode("\n", $body), implode("\n", $kludges)]; + } +} diff --git a/src/MessageHandler.php b/src/MessageHandler.php index 709d22c66..6d5b8a8dd 100644 --- a/src/MessageHandler.php +++ b/src/MessageHandler.php @@ -3006,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 From 3779f40199e1bdc807f6d6f4913c5a44100ae2bc Mon Sep 17 00:00:00 2001 From: awehttam Date: Fri, 7 Aug 2026 19:17:29 -0700 Subject: [PATCH 04/25] Allow plaintext auth fallback for registered downlinks The global security.allow_plaintext_fallback policy (hardened for uplink/public-FTN security) was also blocking plaintext auth for downlink/point connections. Registered hub nodes now always accept plaintext in addition to CRAM-MD5 regardless of that policy, since downlinks are systems under our own administrative control, not the public network the policy is meant to guard. Co-Authored-By: Claude Sonnet 5 --- src/Binkp/Protocol/BinkpSession.php | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Binkp/Protocol/BinkpSession.php b/src/Binkp/Protocol/BinkpSession.php index f4e78d700..8f5a2ffc5 100644 --- a/src/Binkp/Protocol/BinkpSession.php +++ b/src/Binkp/Protocol/BinkpSession.php @@ -1975,13 +1975,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); From e46c5a935003f8c8e9aa49450eefd4cb33e6233b Mon Sep 17 00:00:00 2001 From: awehttam Date: Fri, 7 Aug 2026 19:17:47 -0700 Subject: [PATCH 05/25] Add Downlink Queue viewer to /binkp and overhaul binkp_test_client.php Downlink Queue viewer: new tab on the /binkp status page listing hub_node_outbound rows (metadata only) with a packet inspector reusing the existing queue-viewer modal. Extracted the packet-parsing logic shared by both into src/Binkp/Protocol/PacketInspector.php so the CLI test client below can reuse the exact same dump format instead of a third duplicate implementation. binkp_test_client.php: switched to the real BinkpFrame class for wire framing, added CRAM-MD5 support (--no-cram to force plaintext), fixed file reception (previously M_SKIPed everything the server sent - now receives, sends M_GOT, and dumps .pkt contents), added --compose-netmail to send a real test netmail without hand-crafting a packet file, and fixed EOB handling to always reply and wait for the peer to close rather than terminating on the first round-trip. Bounded EOB replies to avoid an infinite ping-pong discovered during testing against a peer (BinkpSession) that also always-replies with no cap. Co-Authored-By: Claude Sonnet 5 --- config/i18n/de/common.php | 12 + config/i18n/de/errors.php | 2 + config/i18n/en/common.php | 12 + config/i18n/en/errors.php | 2 + config/i18n/es/common.php | 12 + config/i18n/es/errors.php | 2 + config/i18n/fr/common.php | 12 + config/i18n/fr/errors.php | 2 + config/i18n/it/common.php | 12 + config/i18n/it/errors.php | 2 + config/i18n/ru/common.php | 12 + config/i18n/ru/errors.php | 2 + docs/CLI.md | 20 + routes/api-routes.php | 58 +++ scripts/binkp_test_client.php | 692 +++++++++++++++++-------- src/Binkp/Protocol/PacketInspector.php | 159 ++++++ src/Binkp/Web/BinkpController.php | 242 ++++----- templates/binkp.twig | 118 +++++ 18 files changed, 1012 insertions(+), 361 deletions(-) create mode 100644 src/Binkp/Protocol/PacketInspector.php diff --git a/config/i18n/de/common.php b/config/i18n/de/common.php index c1b81fbe7..5c46fcfb3 100644 --- a/config/i18n/de/common.php +++ b/config/i18n/de/common.php @@ -3327,6 +3327,18 @@ 'ui.binkp.status_tab' => 'Status', 'ui.binkp.uplinks_tab' => 'Uplinks', 'ui.binkp.queues_tab' => '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.kept_packets_tab' => 'Kept Packets', 'ui.binkp.kept_packets_locked' => 'Diese Funktion erfordert eine registrierte Lizenz.', 'ui.binkp.kept_packets_register' => 'Register to unlock', diff --git a/config/i18n/de/errors.php b/config/i18n/de/errors.php index d6ecdd4d8..50ef10f5c 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', diff --git a/config/i18n/en/common.php b/config/i18n/en/common.php index d6f1f0c36..cd15bdb48 100644 --- a/config/i18n/en/common.php +++ b/config/i18n/en/common.php @@ -3347,6 +3347,18 @@ 'ui.binkp.status_tab' => 'Status', 'ui.binkp.uplinks_tab' => 'Uplinks', 'ui.binkp.queues_tab' => '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.kept_packets_tab' => 'Kept Packets', 'ui.binkp.kept_packets_locked' => 'This feature requires a registered license.', 'ui.binkp.kept_packets_register' => 'Register to unlock', diff --git a/config/i18n/en/errors.php b/config/i18n/en/errors.php index d3ee0ca67..7eed02478 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', diff --git a/config/i18n/es/common.php b/config/i18n/es/common.php index 3e57bc015..12686b4e8 100644 --- a/config/i18n/es/common.php +++ b/config/i18n/es/common.php @@ -3330,6 +3330,18 @@ 'ui.binkp.status_tab' => 'Estado', 'ui.binkp.uplinks_tab' => 'Uplinks', 'ui.binkp.queues_tab' => 'Colas', + '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.kept_packets_tab' => 'Paquetes guardados', 'ui.binkp.kept_packets_locked' => 'Esta funcion requiere una licencia registrada.', 'ui.binkp.kept_packets_register' => 'Registrarse para desbloquear', diff --git a/config/i18n/es/errors.php b/config/i18n/es/errors.php index 610bcd9f5..43f076dce 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', diff --git a/config/i18n/fr/common.php b/config/i18n/fr/common.php index ce19b5e57..690d1d598 100644 --- a/config/i18n/fr/common.php +++ b/config/i18n/fr/common.php @@ -2777,6 +2777,18 @@ 'ui.binkp.status_tab' => 'État', 'ui.binkp.uplinks_tab' => 'Uplinks', 'ui.binkp.queues_tab' => 'Files d\'attente', + '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.logs_tab' => 'Journaux', 'ui.binkp.system_information' => 'Informations système', 'ui.binkp.loading_system_information' => 'Chargement des informations système...', diff --git a/config/i18n/fr/errors.php b/config/i18n/fr/errors.php index 8d7eae9f9..87cbb67a5 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', diff --git a/config/i18n/it/common.php b/config/i18n/it/common.php index a4ae1836a..88f9f8eb7 100644 --- a/config/i18n/it/common.php +++ b/config/i18n/it/common.php @@ -3329,6 +3329,18 @@ 'ui.binkp.status_tab' => 'Stato', 'ui.binkp.uplinks_tab' => 'Uplink', 'ui.binkp.queues_tab' => 'Code', + '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.kept_packets_tab' => 'Pacchetti conservati', 'ui.binkp.kept_packets_locked' => 'Questa funzionalità richiede una licenza registrata.', 'ui.binkp.kept_packets_register' => 'Registrati per sbloccare', diff --git a/config/i18n/it/errors.php b/config/i18n/it/errors.php index bd73ee477..93b88aff7 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', diff --git a/config/i18n/ru/common.php b/config/i18n/ru/common.php index e6eb86a1a..1a14671f6 100644 --- a/config/i18n/ru/common.php +++ b/config/i18n/ru/common.php @@ -3353,6 +3353,18 @@ 'ui.binkp.status_tab' => 'Статус', 'ui.binkp.uplinks_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.kept_packets_tab' => 'Сохранённые пакеты', 'ui.binkp.kept_packets_locked' => 'Для использования этой функции требуется зарегистрированная лицензия.', 'ui.binkp.kept_packets_register' => 'Зарегистрируйтесь, чтобы разблокировать', diff --git a/config/i18n/ru/errors.php b/config/i18n/ru/errors.php index 23948abbf..c4183d0ea 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' => 'Адрес электронной почты обязателен', diff --git a/docs/CLI.md b/docs/CLI.md index 415dfd414..78032ee41 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -405,6 +405,26 @@ 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. + +```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" + +# 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/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_test_client.php b/scripts/binkp_test_client.php index 4f2a537cb..b5321d916 100755 --- a/scripts/binkp_test_client.php +++ b/scripts/binkp_test_client.php @@ -1,140 +1,123 @@ #!/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 address (required with --compose-netmail)\n"; + echo " --to-name=NAME Netmail To: name (default: sysop)\n"; + echo " --subject=TEXT Netmail subject (default: Test message)\n"; + echo " --body=TEXT Netmail body text\n"; + echo " --body-file=PATH Read netmail body from a file instead of --body\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 --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; - } - - $headerVal = unpack('n', $header)[1]; - $isCommand = ($headerVal & 0x8000) !== 0; - $length = $headerVal & 0x7FFF; - - if ($length === 0) { - return ['is_command' => $isCommand, 'command' => null, 'data' => '']; - } - - // 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 +130,16 @@ function sendDataFrame($socket, $data) { 'sysop' => 'Test Sysop', 'location' => 'Test Location', 'timeout' => 30, + 'no-cram' => false, 'send-file' => null, + 'compose-netmail' => false, + 'to' => null, + 'to-name' => 'sysop', + '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 +164,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 +182,54 @@ function sendDataFrame($socket, $data) { exit(1); } +if ($options['compose-netmail'] && !$options['to']) { + echo "Error: --compose-netmail requires --to=ADDR\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']) { + $body = $options['body']; + if ($options['body-file']) { + if (!file_exists($options['body-file'])) { + echo "Error: --body-file path does not exist: {$options['body-file']}\n"; + exit(1); + } + $body = file_get_contents($options['body-file']); + } + + $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'], + 'subject' => $options['subject'], + 'message_text' => $body, + '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; +} + // Main client logic log_msg("Binkp Test Client Starting"); log_msg("Connecting to {$options['host']}:{$options['port']}..."); @@ -206,150 +249,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/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..a31abcce4 100644 --- a/src/Binkp/Web/BinkpController.php +++ b/src/Binkp/Web/BinkpController.php @@ -270,7 +270,106 @@ 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', + ]; + } + + $bytes = $this->fetchHubOutboundPacketData($id); + 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); + } + } + + /** + * 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). + * + * @return array{filename:string,bytes:string}|null + */ + public function getHubOutboundPacketBytes(int $id): ?array + { + $bytes = $this->fetchHubOutboundPacketData($id); + if ($bytes === null) { + return null; + } + + return [ + 'filename' => 'hub_outbound_' . $id . '.pkt', + 'bytes' => $bytes, + ]; + } + + private function fetchHubOutboundPacketData(int $id): ?string + { + $db = \BinktermPHP\Database::getInstance()->getPdo(); + $stmt = $db->prepare("SELECT packet_data FROM hub_node_outbound WHERE id = ?"); + $stmt->execute([$id]); + $bytes = $stmt->fetchColumn(); + + if ($bytes === false) { + return null; + } + if (is_resource($bytes)) { + $bytes = stream_get_contents($bytes); + } + + return $bytes; + } + public function processInbound() { try { @@ -829,146 +928,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/templates/binkp.twig b/templates/binkp.twig index 6d3af0dd8..cee697986 100644 --- a/templates/binkp.twig +++ b/templates/binkp.twig @@ -58,6 +58,9 @@ +