diff --git a/libervia/backend/core/main.py b/libervia/backend/core/main.py index 5e6b6487..ce49fca9 100644 --- a/libervia/backend/core/main.py +++ b/libervia/backend/core/main.py @@ -31,6 +31,7 @@ from libervia.backend.core import patches from libervia.backend.models.tor import TorData from libervia.backend.tools.config import ( BridgeSettings, + ComponentSettings, DBusBridgeSettings, PbBridgeSettings, ) @@ -296,6 +297,8 @@ class LiberviaBackend(service.Service): self.stopService() sys.exit(C.EXIT_ERROR) + await self._sync_components_profiles() + self.initialised.callback(None) log.info(_("Backend is ready")) @@ -535,6 +538,109 @@ class LiberviaBackend(service.Service): defers_list.append(utils.as_deferred(unload)) return defers_list + async def _sync_components_profiles(self) -> None: + """Synchronize components profiles data with settings. + + ``jid`` and ``secret`` are used in settings as helpers to easily create + autoconnecting components. This method syncs those settings with actual profiles. + """ + log.debug("Synchronizing component settings.") + for component_field_name in G.config.component.__class__.model_fields: + component_settings = cast( + ComponentSettings, getattr(G.config.component, component_field_name) + ) + + if component_settings.jid is not None: + # Automatic setting fields are used + assert component_settings.secret is not None + entry_point = component_settings._import_name + assert entry_point is not None + profile = entry_point + if not G.storage.has_profile(entry_point): + log.info(f"Creating profile {profile!r}") + await G.host.memory.create_profile(profile, "", entry_point) + # FIXME: why do we need to start session to set parameters? To check + # after parameters refactoring. + await G.host.memory.start_session("", profile) + await G.host.memory.param_set( + "JabberID", + str(component_settings.jid), + "Connection", + profile_key=profile, + ) + await G.host.memory.param_set( + "Password", + component_settings.secret, + "Connection", + profile_key=profile, + ) + await G.host.memory.param_set( + "autoconnect_backend", + component_settings.autoconnect, + "Connection", + profile_key=profile, + ) + log.info(f"Component profile {profile!r} set for auto-connection.") + else: + # Profile exists, check if values correspond to settings + try: + # FIXME: As for new profile condition above, we need to check if + # we can skip session starting to set profile parameters. + await G.host.memory.start_session("", profile) + except exceptions.PasswordError: + # Component passwords should be empty to allow for autoconnection. + log.warning( + f"Can't set automatically component profile for {profile}: " + "the password is not empty as expected." + ) + continue + current_jid = await G.host.memory.param_get_a_async( + "JabberID", "Connection", profile_key=profile + ) + if current_jid != str(component_settings.jid): + log.warning( + f"Component profile {profile!r} JID mismatch: " + f"current={current_jid}, settings={component_settings.jid}, " + f"updating from settings" + ) + await G.host.memory.param_set( + "JabberID", + str(component_settings.jid), + "Connection", + profile_key=profile, + ) + + current_password = await G.host.memory.param_get_a_async( + "Password", "Connection", profile_key=profile + ) + if current_password != component_settings.secret: + log.warning( + f"Component profile {profile!r} secret mismatch, " + f"updating from settings" + ) + await G.host.memory.param_set( + "Password", + component_settings.secret, + "Connection", + profile_key=profile, + ) + + current_autoconnect = await G.host.memory.param_get_a_async( + "autoconnect_backend", "Connection", profile_key=profile + ) + expected_autoconnect = component_settings.autoconnect + if current_autoconnect != expected_autoconnect: + log.warning( + f"Component profile {profile!r} autoconnect setting" + f" mismatch, updating from settings" + ) + await G.host.memory.param_set( + "autoconnect_backend", + expected_autoconnect, + "Connection", + profile_key=profile, + ) + def _connect(self, profile_key, password="", options=None): profile = self.memory.get_profile_name(profile_key) return defer.ensureDeferred(self.connect(profile, password, options)) diff --git a/libervia/backend/memory/memory.py b/libervia/backend/memory/memory.py index d9d564ab..42228642 100644 --- a/libervia/backend/memory/memory.py +++ b/libervia/backend/memory/memory.py @@ -30,7 +30,6 @@ from uuid import uuid4 import shortuuid from twisted.internet import defer, error, reactor from twisted.internet.base import DelayedCall -from twisted.python import failure from twisted.words.protocols.jabber import jid from libervia.backend.core import exceptions @@ -44,7 +43,6 @@ from libervia.backend.memory.disco import Discovery from libervia.backend.memory.params import Params from libervia.backend.memory.persistent import PersistentDict from libervia.backend.memory.sqla import ( - Notification, NotificationPriority, NotificationStatus, NotificationType, @@ -518,15 +516,19 @@ class Memory: self.memory_data["Profile_default"] = profile - def create_profile(self, name, password, component=None): + def create_profile( + self, name: str, password: str, component: str | None = None + ) -> defer.Deferred[None]: """Create a new profile - @param name(unicode): profile name - @param password(unicode): profile password - Can be empty to disable password - @param component(None, unicode): set to entry point if this is a component - @return: Deferred - @raise exceptions.NotFound: component is not a known plugin import name + @param name: profile name + @param password: profile password (can be empty to disable password) + @param component: entry point if this is a component + @return: Deferred that fires when the profile is created + @raise ValueError: if profile name is empty, starts with '@', or contains line + feed + @raise exceptions.ConflictError: if a session for this profile exists + @raise exceptions.NotFound: if component is not a known plugin import name """ if not name: raise ValueError("Empty profile name") @@ -547,7 +549,8 @@ class Memory: ) ) ) - # FIXME: PLUGIN_INFO is not currently accessible after import, but type shoul be tested here + # FIXME: PLUGIN_INFO is not currently accessible after import, but type should + # be tested here #  if self.host.plugins[component].PLUGIN_INFO[u"type"] != C.PLUG_TYPE_ENTRY_POINT: #   raise ValueError(_(u"Plugin {component} is not an entry point !".format( #   component = component))) @@ -612,12 +615,12 @@ class Memory: """ return self.storage.profile_is_component(profile_name) - def get_entry_point(self, profile_name): - """Get a component entry point + def get_entry_point(self, profile_name: str) -> str: + """Get a component entry point for a given profile. - @param profile_name(unicode): name of the profile - @return (bool): True if profile is a component - @raise exceptions.NotFound: profile doesn't exist + @param profile_name: name of the profile + @return: the component entry point (import name) for the profile + @raise exceptions.NotFound: if the profile doesn't exist or is not a component """ return self.storage.get_entry_point(profile_name) diff --git a/libervia/backend/tools/config.py b/libervia/backend/tools/config.py index ba4d5548..797e73e8 100644 --- a/libervia/backend/tools/config.py +++ b/libervia/backend/tools/config.py @@ -22,7 +22,7 @@ from collections import defaultdict from pathlib import Path from socket import getfqdn -from typing import Annotated, Any, Literal, TypeVar, cast +from typing import Annotated, Any, Literal, Self, TypeVar, cast from pydantic import ( AfterValidator, @@ -32,6 +32,7 @@ from pydantic import ( ValidationError, create_model, field_validator, + model_validator, ) from pydantic.types import PathType from pydantic_settings import ( @@ -45,6 +46,7 @@ from libervia.backend.core import exceptions from libervia.backend.core.constants import Const as C, xdg_data_home from libervia.backend.core.i18n import _ from libervia.backend.core.log import getLogger +from libervia.backend.models.types import JIDType from libervia.backend.tools.common.files_utils import get_downloads_dir log = getLogger(__name__) @@ -103,6 +105,7 @@ class DynamicSettingsBase(ConfigOptions): _key: str = "" _sub_key: str | None = None + _import_name: str | None = None @classmethod def _init_subclass(cls, /, key: str, plugin_info: dict | None = None) -> None: @@ -127,6 +130,7 @@ class DynamicSettingsBase(ConfigOptions): f"There is already a plugin registered for {key!r}.{sub_key!r}." ) register[sub_key] = cls + cls._import_name = plugin_info.get(C.PI_IMPORT_NAME) else: cls._sub_key = None DynamicSettings[key] = cls @@ -143,10 +147,40 @@ class PluginSettings(DynamicSettingsBase): class ComponentSettings(DynamicSettingsBase): """Configuration options for components.""" + jid: JIDType | None = Field( + default=None, + description=( + "JID of the component. If used, the component profile will be created" + " automatically (with the component entry point used as name), and" + " auto-connect on backend startup." + ), + ) + secret: str | None = Field( + default=None, + description=( + "Shared secret between the component and the XMPP server. To be used with" + " ``jid`` field for automatic configuration and connetion." + ), + ) + autoconnect: bool = Field( + default=True, + description=( + "Whether to automatically connect this component on backend startup. Used" + " when ``jid`` and ``secret`` are set." + ), + ) + def __init_subclass__(cls, /, plugin_info: dict, **kwargs) -> None: super().__init_subclass__(**kwargs) super()._init_subclass(key="component", plugin_info=plugin_info) + @model_validator(mode="after") + def jid_and_secret_together(self) -> Self: + """Check that "jid" and "secret" are (un)set at the same time""" + if (self.jid is None) ^ (self.secret is None): + raise ValueError('You must set "jid" and "secret" together.') + return self + class FrontendSettings(DynamicSettingsBase):