IETF System Plugin
IETF System Plugin is developed around the IETF System Management YANG data model. The YANG model can be found here (opens in a new tab). The plugin revolves around maintaining and configuring basic system properties such as a hostname, timezone, DNS servers, NTP servers, System Users etc.
The plugin is currently being ported to C++ while the C version of the plugin is being maintained in the meantime.
On the GitHub repository, you can check out the cpp
branch where the plugin is currently being developed.
Here, we will focus on the C++ version since it will be used in the future.
Firstly, we will name all needed dependencies and will explain the purpose behind their usage.
After that, we will explain how the plugin uses the sysrepo-plugins-common
library (C++ version of the library is also being developed alongside the plugin) to create and register its modules.
With all that covered, the rest simply involves reading the code and looking into each class and it's API. One example will be given of a class which is used to apply and receive data from the system. We will do that by using the Hostname module as the example. All other modules follow the same principle and should be self explanatory.
With that being said, lets start by mentioning the dependencies for the IETF System Plugin.
Dependencies
The following repositories are being used as dependencies for developing the IETF System Plugin (excluding libyang and sysrepo and their C++ versions):
sysrepo-plugins-common
has its own C++ version named srpcpp
. It is being used for adding common functionalities which can be found in all plugins.
You can install it by checking out the cpp
branch and building the C++ version which can be found in the cpp/
directory.
As for the sdbus-cpp
library, you can build it by following the instructions in the README of the repository.
sd-bus is being used for sending data and receiving data from systemd. sdbus-cpp is being used since C++ is used in the plugin and since it offers the sd-bus API in a modern, template based way.
umgmt
library is used in the plugin for the Authentication Module. It is used for manipulating user accounts on the system.
Core
The core of the plugin consists of some common constats, a plugin context, IP address implementations and an sd-bus interface class. The code in the core of the plugin should be self explanatory. We can have a look at the sd-bus interface for a better understanding:
#pragma once
#include <sdbus-c++/sdbus-c++.h>
#include <string>
// error messages
#include <sysrepo.h>
namespace ietf::sys {
template <class GET, class... SET> class SdBus {
public:
SdBus(std::string destination, std::string objectPath, std::string interface, std::string set_method, std::string property)
: m_dest(destination)
, m_objPath(objectPath)
, m_interface(interface)
, m_setMethod(set_method)
, m_property(property)
{
}
protected:
void exportToSdBus(SET... data)
{
try {
auto proxy = sdbus::createProxy(m_dest, m_objPath);
proxy->callMethod(m_setMethod).onInterface(m_interface).withArguments(data...);
} catch (sdbus::Error& e) {
SRPLG_LOG_ERR("sd-bus", "Error exporting data to sd-bus: %s", e.what());
throw std::runtime_error(e.getMessage());
};
}
GET importFromSdBus()
{
GET data;
try {
auto proxy = sdbus::createProxy(m_dest, m_objPath);
sdbus::Variant v = proxy->getProperty(m_property).onInterface(m_interface);
data = v.get<GET>();
} catch (sdbus::Error& e) {
SRPLG_LOG_ERR("sd-bus", "Error importing data from sd-bus: %s", e.what());
throw std::runtime_error(e.getMessage());
}
return data;
}
private:
std::string m_dest;
std::string m_objPath;
std::string m_interface;
std::string m_setMethod;
std::string m_property;
};
}
The class uses template parameters for accessing the sd-bus interface on the system.
GET template type is used for determining which type will be returned from sd-bus to us by using the importFromSdBus()
method.
SET template types determine what parameters need to be given when setting the data to sd-bus by using the exportToSdBus()
method.
Classes which deal with data that can be manipulated using sd-bus like Hostname and Timezone can inherit this class and use named protected methods to set/get their values to/from the system.
Modules
The plugin contains the following modules:
- Overall System Module
- Hostname Module
- Timezone Module
- DNS Module
- Authentication Module
- NTP Module
As you can see, modules are (mostly) split by the containers of the YANG module. Each module contains some of the following sections (each one is optional, depending on the YANG module):
- operational callbacks
- change callbacks
- RPC callbacks
- API for getting and setting system data
For example, DNS module contains change and operational callbacks for the server
and search
lists and also an API used to access those lists on the system by using the mentioned sd-bus interface.
System Module
Overall System Module contains operational callbacks for the platform information and clock information. It also includes RPC callbacks for restarting and shutting down the system.
It also contains the following API for getting operational data:
/**
* @brief Platform information helper struct.
*/
struct PlatformInfo {
std::string OsName; ///< OS name.
std::string OsRelease; ///< OS release.
std::string OsVersion; ///< OS version.
std::string Machine; ///< Machine.
};
/**
* @brief Get platform information.
*
* @return Platform information.
*/
PlatformInfo getPlatformInfo();
/**
* @brief Clock information helper struct.
*/
struct ClockInfo {
std::string BootDatetime; ///< Boot datetime.
std::string CurrentDatetime; ///< Current datetime.
};
/**
* @brief Get clock information.
*
* @return Clock information.
*/
ClockInfo getClockInfo();
Hostname and Timezone Modules
Even though Hostname and Timezone are a part of the system
container, they are abstracted from the Overall System Module since they contain their own logic and use sd-bus for configuration.
We could've also included their callbacks in the overall system module but for now it is separated for better code quality and readability.
Hostname
class Hostname : public SdBus<std::string, std::string, bool> {
public:
/**
* @brief Hostname constructor.
*/
Hostname();
/**
* @brief Get the system hostname.
*
* @return System hostname.
*/
std::string getValue(void);
/**
* @brief Set the systme hostname.
*
* @param hostname Hostname to set.
*/
void setValue(const std::string& hostname);
};
An instance of the Hostname class can be thought of as a handle to the sd-bus Hostname value. Once the instance of the class is created, it can be used to set/get the hostname value to/from the system. Hostname class uses the following sd-bus values:
/**
* @brief Hostname constructor.
*/
Hostname::Hostname()
: SdBus<std::string, std::string, bool>(
"org.freedesktop.hostname1", "/org/freedesktop/hostname1", "org.freedesktop.hostname1", "SetStaticHostname", "Hostname")
{
}
SetStaticHostname()
is used for setting the hostname and Hostname
property is used for accessing the system hostname.
Timezone
Timezone class follows the same principle as the Hostname class:
class TimezoneName : public SdBus<std::string, std::string, bool> {
public:
/**
* @brief Default constructor.
*/
TimezoneName();
/**
* @brief Get timezone name value from the system.
*
* @return System timezone name.
*/
std::string getValue(void);
/**
* @brief Set the timezone name on the system.
*
* @param timezone_name Timezone to set.
*/
void setValue(const std::string& timezone_name);
};
/**
* @brief Default constructor.
*/
TimezoneName::TimezoneName()
: SdBus<std::string, std::string, bool>(
"org.freedesktop.timedate1", "/org/freedesktop/timedate1", "org.freedesktop.timedate1", "SetTimezone", "Timezone")
{
}
SetTimezone()
is used for setting and Timezone
property is used for getting the Timezone value.
DNS Module
DNS Module also uses the sd-bus interface for configuring DNS servers and DNS search values.
/**
* @brief DNS server helper struct.
*/
struct DnsServer {
int32_t InterfaceIndex; ///< Interface index used for the DNS server.
std::unique_ptr<ip::IAddress> Address; ///< IP address of the server.
uint16_t Port; ///< Port used for the server. Defaults to 53.
std::string Name; ///< Server Name Indication.
/**
* @brief Default constructor.
*/
DnsServer();
/**
* @brief Set the IP address of the server.
*
* @param address IP address (IPv4 or IPv6).
*/
void setAddress(const std::string& address);
/**
* @brief Set the port of the server.
*
* @param port Port to set.
*/
void setPort(std::optional<uint16_t> port);
};
/**
* @brief DNS search helper struct.
*/
struct DnsSearch {
int InterfaceIndex; ///< Interface index of the search element. 0 used for global configuration.
std::string Domain; ///< Domain of the search element.
bool Search; ///< Boolean value indicating wether the value is used for routing (true) or for both routing and searching (false).
};
/**
* @brief DNS server list class used for loading and storing a list of DNS servers.
*/
class DnsServerList : public SdBus<std::vector<sdbus::Struct<int32_t, int32_t, std::vector<uint8_t>, uint16_t, std::string>>, int32_t,
std::vector<sdbus::Struct<int32_t, std::vector<uint8_t>, uint16_t, std::string>>> {
public:
/**
* @brief Default constructor.
*/
DnsServerList();
/**
* @brief Loads the list of DNS servers found currently on the system.
*/
void loadFromSystem();
/**
* @brief Stores the list of DNS servers in the class to the system.
*/
void storeToSystem();
/**
* @brief Create a new server and add it to the list.
*
* @param name Name of the DNS server.
* @param address IP address of the DNS server.
* @param port Optional port value of the DNS server. If no value provided, 53 is used.
*/
void createServer(const std::string& name, const std::string& address, std::optional<uint16_t> port);
/**
* @brief Change the IP address of the given server with the provided name.
*
* @param name Name of the server to change.
* @param address New address to set.
*/
void changeServerAddress(const std::string& name, const std::string& address);
/**
* @brief Change the port of the given server with the provided name.
*
* @param name Name of the server to change.
* @param port New port to set.
*/
void changeServerPort(const std::string& name, const uint16_t port);
/**
* @brief Delete server from the list.
*
* @param name Name of the DNS server.
*/
void deleteServer(const std::string& name);
/**
* @brief Get iterator to the beginning.
*/
auto begin() { return m_servers.begin(); }
/**
* @brief Get iterator to the end.
*/
auto end() { return m_servers.end(); }
private:
/**
* @brief Helper function for finding DNS server by the provided name.
*
* @param name Name to use for search.
*
* @return Iterator pointing to the DNS server with the provided name.
*/
std::optional<std::list<DnsServer>::iterator> m_findServer(const std::string& name);
int m_ifindex; ///< Interface index used for this list.
std::list<DnsServer> m_servers; ///< List of DNS servers.
};
/**
* @breif DNS search list class used for loading and storing a list of DNS search domains.
*/
class DnsSearchList : public SdBus<std::vector<sdbus::Struct<int32_t, std::string, bool>>, int32_t, std::vector<sdbus::Struct<std::string, bool>>> {
public:
/**
* @brief Default constructor.
*/
DnsSearchList();
/**
* @brief Loads the list of DNS servers found currently on the system.
*/
void loadFromSystem();
/**
* @brief Add new search domain to the list.
*
* @param domain Search domain to create.
*/
void createSearchDomain(const std::string& domain);
/**
* @brief Delete search domain from the list.
*
* @param domain Search domain to remove.
*/
void deleteSearchDomain(const std::string& domain);
/**
* @brief Stores the list of DNS servers in the class to the system.
*/
void storeToSystem();
/**
* @brief Get iterator to the beginning.
*/
auto begin() { return m_search.begin(); }
/**
* @brief Get iterator to the end.
*/
auto end() { return m_search.end(); }
private:
int m_ifindex; ///< Interface index used for this list.
std::list<DnsSearch> m_search; ///< List of DNS search domains.
};
Both lists contain functions for loading and storing data inside of them. The list API can be used as follows:
// creates handle to sd-bus values
DnsServerList servers;
// load data from system using sd-bus internally and store it to an internal list of servers of type DnsServer
try {
servers.loadFromSystem();
} catch(...) {
}
// use API to change the internal list
try {
servers.createServer(...)
servers.changeServerAddress(...)
.
.
.
} catch(...) {
}
// store changed data to the system
try {
servers.storeToSystem();
} catch(...) {
}
The same style applies to the search domains list API.
Authentication Module
Authentication Module deals with user accounts on the system and uses the umgmt
library as described before.
It contains a few classes for implementing the user manipulation API which is then used in the module change callbacks and in operational callbacks of the module.
The authentication API is very similar to the DNS API but it uses the umgmt
library instead of the sd-bus API.
Auth API classes can be thought of as wrappers around the initial C umgmt
structures.
So, for example, instead of using the users list API directly, mostly the DatabaseContext class is used.
/**
* @brief Authorized key helper struct.
*/
struct AuthorizedKey {
std::string Name; ///< Key name.
std::string Algorithm; ///< Key algorithm.
std::string Data; ///< Key data.
};
/**
* @brief Authorized key list.
*/
class AuthorizedKeyList {
public:
/**
* @brief Construct a new Authorized Key List object.
*/
AuthorizedKeyList();
/**
* @brief Load authorized keys from the system.
*
* @param username Username of the keys owner.
*/
void loadFromSystem(const std::string& username);
/**
* @brief Store authorized keys to the system.
*
* @param username Username of the keys owner.
*/
void storeToSystem(const std::string& username);
/**
* @brief Get iterator to the beginning.
*/
std::list<AuthorizedKey>::iterator begin() { return m_keys.begin(); }
/**
* @brief Get iterator to the end.
*/
std::list<AuthorizedKey>::iterator end() { return m_keys.end(); }
private:
std::list<AuthorizedKey> m_keys;
};
/**
* @brief Local user helper struct.
*/
struct LocalUser {
std::string Name; ///< User name.
std::optional<std::string> Password; ///< User password hash.
std::optional<AuthorizedKeyList> AuthorizedKeys; ///< User authorized keys.
};
/**
* @brief Local user list.
*/
class LocalUserList {
public:
/**
* @brief Construct a new Local User List object.
*/
LocalUserList();
/**
* @brief Load local users from the system.
*/
void loadFromSystem();
/**
* @brief Store local users to the system.
*/
void storeToSystem();
/**
* @brief Add new user.
*
* @param name User name.
* @param password User password.
*/
void addUser(const std::string& name, std::optional<std::string> password);
/**
* @brief Change user password to a new value.
*
* @param name User name.
* @param password Password to set.
*/
void changeUserPassword(const std::string& name, std::string password);
/**
* @brief Get iterator to the beginning.
*/
std::list<LocalUser>::iterator begin() { return m_users.begin(); }
/**
* @brief Get iterator to the end.
*/
std::list<LocalUser>::iterator end() { return m_users.end(); }
private:
std::list<LocalUser> m_users;
};
class DatabaseContext {
public:
DatabaseContext();
/**
* @brief Load authentication database from the system.
*/
void loadFromSystem(void);
/**
* @brief Create user in the database. Adds the user to the list of new users.
*
* @param user User name of the new user.
*/
void createUser(const std::string& name);
/**
* @brief Delete user with the given name from the database. Adds the user to the list of users to be deleted.
*
* @param name User name of the user to remove.
*/
void deleteUser(const std::string& name);
/**
* @brief Modify the password hash for the given user.
*
* @param name User name.
* @param password_hash Password hash to set.
*/
void modifyUserPasswordHash(const std::string& name, const std::string& password_hash);
/**
* @brief Delete the password hash for the given user.
*
* @param name User name.
*/
void deleteUserPasswordHash(const std::string& name);
/**
* @brief Store database context changes to the system.
*/
void storeToSystem(void);
private:
um_db_t* m_db;
};
As we can see, the DatabaseContext class uses the same approach as the lists API. First you create the database context, after that you manipulate data inside the database and finally apply it to the system. The following is the example of how this class is being used in a change callback for local users:
/**
* sysrepo-plugin-generator: Generated module change operator() for path /ietf-system:system/authentication/user[name='%s'].
*
* @param session An implicit session for the callback.
* @param subscriptionId ID the subscription associated with the callback.
* @param moduleName The module name used for subscribing.
* @param subXPath The optional xpath used at the time of subscription.
* @param event Type of the event that has occured.
* @param requestId Request ID unique for the specific module_name. Connected events for one request (SR_EV_CHANGE and
* SR_EV_DONE, for example) have the same request ID.
*
* @return Error code.
*
*/
sr::ErrorCode AuthUserModuleChangeCb::operator()(sr::Session session, uint32_t subscriptionId, std::string_view moduleName,
std::optional<std::string_view> subXPath, sr::Event event, uint32_t requestId)
{
sr::ErrorCode error = sr::ErrorCode::Ok;
// create database context
auth::DatabaseContext db;
switch (event) {
case sysrepo::Event::Change:
// load the database before iterating changes
try {
db.loadFromSystem();
} catch (std::runtime_error& err) {
SRPLG_LOG_ERR(ietf::sys::PLUGIN_NAME, "Error loading user database: %s", err.what());
error = sr::ErrorCode::OperationFailed;
}
// apply user changes to the database context
for (auto& change : session.getChanges("/ietf-system:system/authentication/user/name")) {
SRPLG_LOG_DBG(ietf::sys::PLUGIN_NAME, "Value of %s modified.", change.node.path().c_str());
SRPLG_LOG_DBG(ietf::sys::PLUGIN_NAME, "Value of %s modified.", change.node.schema().name().data());
SRPLG_LOG_DBG(
ietf::sys::PLUGIN_NAME, "\n%s", change.node.printStr(libyang::DataFormat::XML, libyang::PrintFlags::WithDefaultsAll)->data());
// extract value
const auto& value = change.node.asTerm().value();
const auto& name = std::get<std::string>(value);
SRPLG_LOG_DBG(PLUGIN_NAME, "User name: %s", name.c_str());
switch (change.operation) {
case sysrepo::ChangeOperation::Created:
// create a new user in the DatabaseContext
try {
db.createUser(name);
} catch (std::runtime_error& err) {
SRPLG_LOG_ERR(ietf::sys::PLUGIN_NAME, "Error creating user: %s", err.what());
error = sr::ErrorCode::OperationFailed;
}
break;
case sysrepo::ChangeOperation::Modified:
break;
case sysrepo::ChangeOperation::Deleted:
try {
db.deleteUser(name);
} catch (std::runtime_error& err) {
SRPLG_LOG_ERR(ietf::sys::PLUGIN_NAME, "Error deleting user: %s", err.what());
error = sr::ErrorCode::OperationFailed;
}
break;
case sysrepo::ChangeOperation::Moved:
break;
}
}
// apply password changes to the database context
for (auto& change : session.getChanges("/ietf-system:system/authentication/user/password")) {
SRPLG_LOG_DBG(ietf::sys::PLUGIN_NAME, "Value of %s modified.", change.node.path().c_str());
SRPLG_LOG_DBG(ietf::sys::PLUGIN_NAME, "Value of %s modified.", change.node.schema().name().data());
SRPLG_LOG_DBG(
ietf::sys::PLUGIN_NAME, "\n%s", change.node.printStr(libyang::DataFormat::XML, libyang::PrintFlags::WithDefaultsAll)->data());
// extract value
const auto& value = change.node.asTerm().value();
const auto& password_hash = std::get<std::string>(value);
const auto& name = srpc::extractListKeyFromXPath("user", "name", change.node.path());
SRPLG_LOG_DBG(PLUGIN_NAME, "User name: %s", name.c_str());
SRPLG_LOG_DBG(PLUGIN_NAME, "User password: %s", password_hash.c_str());
switch (change.operation) {
case sysrepo::ChangeOperation::Created:
case sysrepo::ChangeOperation::Modified:
// create/modify user password in the DatabaseContext
try {
db.modifyUserPasswordHash(name, password_hash);
} catch (std::runtime_error& err) {
SRPLG_LOG_ERR(ietf::sys::PLUGIN_NAME, "Error createing user password: %s", err.what());
error = sr::ErrorCode::OperationFailed;
}
break;
case sysrepo::ChangeOperation::Deleted:
try {
db.deleteUserPasswordHash(name);
} catch (std::runtime_error& err) {
SRPLG_LOG_ERR(ietf::sys::PLUGIN_NAME, "Error deleting user password: %s", err.what());
error = sr::ErrorCode::OperationFailed;
}
break;
case sysrepo::ChangeOperation::Moved:
break;
}
}
// apply database context to the system
try {
db.storeToSystem();
} catch (const std::runtime_error& err) {
SRPLG_LOG_ERR(ietf::sys::PLUGIN_NAME, "Error storing database context changes to the system: %s", err.what());
error = sr::ErrorCode::OperationFailed;
}
break;
default:
break;
}
return error;
}
DatabaseContext usage can be seen on the highlighted lines in the previous code snippet. As mentioned before, it is very similar to the DNS server and search list classes usage.
Now that we've seen all the existing modules, we can see how the plugin init callback is implemented and how the modules are registered.
Plugin Init Callback
/**
* @brief Plugin init callback.
*
* @param session Plugin session.
* @param priv Private data.
*
* @return Error code (SR_ERR_OK on success).
*/
int sr_plugin_init_cb(sr_session_ctx_t* session, void** priv)
{
sr::ErrorCode error = sysrepo::ErrorCode::Ok;
auto sess = sysrepo::wrapUnmanagedSession(session);
auto& registry(srpc::ModuleRegistry<ietf::sys::PluginContext>::getInstance());
auto ctx = new ietf::sys::PluginContext(sess);
*priv = static_cast<void*>(ctx);
// create session subscriptions
SRPLG_LOG_INF(ctx->getPluginName(), "Creating plugin subscriptions");
// register all modules
registry.registerModule<SystemModule>(*ctx);
registry.registerModule<HostnameModule>(*ctx);
registry.registerModule<TimezoneModule>(*ctx);
registry.registerModule<DnsModule>(*ctx);
registry.registerModule<AuthModule>(*ctx);
auto& modules = registry.getRegisteredModules();
// for all registered modules - apply startup datastore values
// startup datastore values are coppied into the running datastore when the first connection with sysrepo is made
for (auto& mod : modules) {
SRPLG_LOG_INF(ctx->getPluginName(), "Applying startup values for module %s", mod->getName());
for (auto& applier : mod->getValueAppliers()) {
try {
applier->applyDatastoreValues(sess);
} catch (const std::runtime_error& err) {
SRPLG_LOG_INF(ctx->getPluginName(), "Failed to apply datastore values for the following paths:");
for (const auto& path : applier->getPaths()) {
SRPLG_LOG_INF(ctx->getPluginName(), "\t%s", path.c_str());
}
}
}
}
// get registered modules and create subscriptions
for (auto& mod : modules) {
SRPLG_LOG_INF(ctx->getPluginName(), "Registering operational callbacks for module %s", mod->getName());
srpc::registerOperationalSubscriptions(sess, *ctx, mod);
SRPLG_LOG_INF(ctx->getPluginName(), "Registering module change callbacks for module %s", mod->getName());
srpc::registerModuleChangeSubscriptions(sess, *ctx, mod);
SRPLG_LOG_INF(ctx->getPluginName(), "Registering RPC callbacks for module %s", mod->getName());
srpc::registerRpcSubscriptions(sess, *ctx, mod);
SRPLG_LOG_INF(ctx->getPluginName(), "Registered module %s", mod->getName());
}
SRPLG_LOG_INF("ietf-system-plugin", "Created plugin subscriptions");
return static_cast<int>(error);
}
First we create a reference to the modules registry instance (singleton design pattern). We then use that instance to first apply current datastore values to the system. If you remember from the sysrepo docs, once the plugin first starts, data is coppied from the startup datastore to the running datastore. That means we have to sync the current running datastore with system values. For example, if you change hostname value in the running datastore (you've changed the current configuration on the system), that doesn't mean you've set it to the startup datastore. If certain startup values exist, they will be coppied to the running datastore and after that each module contains it's data appliers. Those data appliers are used to apply the received session data to the system if needed. After all those operations are completed succeessfully, all callbacks from each module are retreived and subscribed using the sysrepo subscription API. Once that is finished, we've set up our sysrepo plugin and the rest is up to the change, operational and RPC callbacks from each module.