Skip to content

Commit

Permalink
VPN-4798: Linux cgroup application matching heuristics (#8801)
Browse files Browse the repository at this point in the history
* Add method to convert desktop file path to desktop ID
* Use desktop ID instead of file path for app matching
* Parse desktop ID out of Gnome and Flatpak scope names
* Remove PIDs from AppTracker public API
* Remove use of GTK launched signal, it has been unreliable
* Remove AppData class, it doens't do anything useful anymore
* Initial attempt to parse snap cgroup scopes
* Better state tracking of control groups
* Remove unused methods in D-Bus API
* Use filename for fallback case when converting desktop file IDs.
* Add more comments to explain the AppTracker class
  • Loading branch information
oskirby authored Dec 18, 2023
1 parent a444bb3 commit caf70fc
Show file tree
Hide file tree
Showing 7 changed files with 260 additions and 228 deletions.
194 changes: 115 additions & 79 deletions src/platforms/linux/daemon/apptracker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,25 +26,21 @@ constexpr const char* DBUS_SYSTEMD_UNIT = "org.freedesktop.systemd1.Unit";

namespace {
Logger logger("AppTracker");
QString s_cgroupMount;
} // namespace

AppTracker::AppTracker(QObject* parent) : QObject(parent) {
MZ_COUNT_CTOR(AppTracker);
logger.debug() << "AppTracker created.";

/* Monitor for changes to the user's application control groups. */
s_cgroupMount = LinuxDependencies::findCgroup2Path();
m_cgroupMount = LinuxDependencies::findCgroup2Path();
}

AppTracker::~AppTracker() {
MZ_COUNT_DTOR(AppTracker);
logger.debug() << "AppTracker destroyed.";

for (AppData* data : m_runningApps) {
delete data;
}
m_runningApps.clear();
m_runningCgroups.clear();
}

void AppTracker::userCreated(uint userid, const QString& xdgRuntimePath) {
Expand All @@ -69,22 +65,13 @@ void AppTracker::userCreated(uint userid, const QString& xdgRuntimePath) {
QDBusConnection connection =
QDBusConnection::connectToBus(busPath, "user-" + QString::number(userid));

/* Connect to the user's GTK launch event. */
bool gtkConnected = connection.connect(
"", GTK_DESKTOP_APP_PATH, GTK_DESKTOP_APP_SERVICE, "Launched", this,
SLOT(gtkLaunchEvent(const QByteArray&, const QString&, qlonglong,
const QStringList&, const QVariantMap&)));
if (!gtkConnected) {
logger.warning() << "Failed to connect to GTK Launched signal";
}

// Watch the user's control groups for new application scopes.
m_systemdInterface =
new QDBusInterface(DBUS_SYSTEMD_SERVICE, DBUS_SYSTEMD_PATH,
DBUS_SYSTEMD_MANAGER, connection, this);
QVariant qv = m_systemdInterface->property("ControlGroup");
if (!s_cgroupMount.isEmpty() && qv.type() == QVariant::String) {
QString userCgroupPath = s_cgroupMount + qv.toString();
if (!m_cgroupMount.isEmpty() && qv.type() == QVariant::String) {
QString userCgroupPath = m_cgroupMount + qv.toString();
logger.debug() << "Monitoring Control Groups v2 at:" << userCgroupPath;

connect(&m_cgroupWatcher, SIGNAL(directoryChanged(QString)), this,
Expand All @@ -104,62 +91,135 @@ void AppTracker::userRemoved(uint userid) {
QDBusConnection::disconnectFromBus("user-" + QString::number(userid));
}

void AppTracker::gtkLaunchEvent(const QByteArray& appid, const QString& display,
qlonglong pid, const QStringList& uris,
const QVariantMap& extra) {
Q_UNUSED(display);
Q_UNUSED(uris);
Q_UNUSED(extra);
// static
// Expand unicode escape sequences.
QString AppTracker::decodeUnicodeEscape(const QString& str) {
static const QRegularExpression re("(\\\\x[0-9A-Fa-f][0-9A-Fa-f])");

QString result = str;
qsizetype offset = 0;
while (offset < result.length()) {
// Search for the next unicode escape sequence.
QRegularExpressionMatch match = re.match(result, offset);
if (!match.hasMatch()) {
break;
}

bool okay;
qsizetype start = match.capturedStart(0);
QChar code = match.captured(0).mid(2).toUShort(&okay, 16);
if (okay && (code != 0)) {
// Replace the matched escape sequence with the decoded character.
result.replace(start, match.capturedLength(0), QString(code));
offset = start + 1;
} else {
// If we failed to decode the character, skip passed the matched string.
offset = match.capturedEnd(0);
}
}

return result;
}

QString appIdName(appid);
while (appIdName.endsWith('\0')) {
appIdName.chop(1);
// The naming convention for snaps follows one of the following formats:
// snap.<pkg>.<app>.service - assigned by systemd for services
// snap.<pkg>.<app>-<uuid>.scope - transient scope for apps
// snap.<pkg>.hook.<app>-<uuid>.scope - transient scope for hooks
//
// However, at some point the separator between the app and UUID was
// swapped from a dot to a dash. Which makes the parsing a bit of a pain.
//
// See: https://github.com/snapcore/snapd/blob/master/sandbox/cgroup/scanning.go
QString AppTracker::snapDesktopFileId(const QString& scope) {
static const QRegularExpression snapuuid(
"[-.][0-9a-fA-F]{8}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}"
"\\b-[0-9a-fA-F]{12}");

// Strip the UUID out of the scope name
QString stripped(scope);
stripped.remove(snapuuid);

// Split the remainder on dots and discard the extension.
QStringList split = stripped.split('.');
split.removeLast();

// Parse the package and application.
QString package = split.value(1);
QString app = split.value(2);
if (app == "hook") {
app = split.value(3);
}
if (!appIdName.isEmpty()) {
m_lastLaunchName = appIdName;
m_lastLaunchPid = pid;
if (package.isEmpty() || app.isEmpty()) {
return QString();
}

// Reassemble the desktop identifier.
return QString("%1_%2.desktop").arg(package).arg(app);
}

void AppTracker::appHeuristicMatch(AppData* data) {
// If this cgroup contains the last-launched PID, then we have a fairly
// strong indication of which application this control group is running.
for (int pid : data->pids()) {
if ((pid != 0) && (pid == m_lastLaunchPid)) {
logger.debug() << data->cgroup << "matches app:" << m_lastLaunchName;
data->appId = m_lastLaunchName;
data->rootpid = m_lastLaunchPid;
break;
// Make an attempt to resolve the desktop ID from a cgroup scope.
QString AppTracker::findDesktopFileId(const QString& cgroup) {
QString scopeName = QFileInfo(cgroup).fileName();

// Reverse the desktop ID from a cgroup scope and known launcher tools.
if (scopeName.startsWith("app-gnome-") ||
scopeName.startsWith("app-flatpak-")) {
// These take the forms:
// app-gnome-<desktopFileId>-<pid>.scope
// app-flatpak-<desktopFileId>-<pid>.scope
//
// Some characters (typically hyphens) in the desktop file ID may be encoded
// as unicode escape sequences to preseve this formatting.
QString raw = scopeName.section('-', 2, 2);
if (!raw.isEmpty()) {
return QString("%1.desktop").arg(decodeUnicodeEscape(raw));
}
}

QString gnomeLaunchdPrefix("gnome-launched-");
if (scopeName.startsWith(gnomeLaunchdPrefix)) {
// These take the form:
// gnome-launched-<desktopFileId>-<pid>.scope
//
// We have seen this on older Gnome desktop environments (eg: Ubuntu 20.04),
// and there is no escaping on the desktopFileId, meaning that it might
// contain embedded hyphens. Therefore, we search for the final hyphen that
// separates the desktopFileId from the PID.
qsizetype start = gnomeLaunchdPrefix.length();
qsizetype end = scopeName.lastIndexOf('-');
if (end > start) {
return scopeName.mid(start, end - start);
}
}

// Snaps have their own format.
if (scopeName.startsWith("snap.")) {
return snapDesktopFileId(scopeName);
}

// Query the systemd unit for its SourcePath property, which is set to the
// desktop file's full path on KDE
QString unit = QFileInfo(data->cgroup).fileName();
QDBusReply<QDBusObjectPath> objPath =
m_systemdInterface->call("GetUnit", unit);
m_systemdInterface->call("GetUnit", scopeName);

QDBusInterface interface(DBUS_SYSTEMD_SERVICE, objPath.value().path(),
DBUS_SYSTEMD_UNIT, m_systemdInterface->connection(),
this);
QString source = interface.property("SourcePath").toString();
if (!source.isEmpty() && source.endsWith(".desktop")) {
data->appId = source;
return LinuxDependencies::desktopFileId(source);
}

// TODO: Some comparison between the .desktop file and the directory name
// of the control group is also very likely to produce viable application
// matching, but this will have to be a fuzzy match of some sort because
// there's a lot of variability in how desktop environments choose to name
// them.
// Otherwise, we don't know the desktop ID for this control group.
return QString();
}

void AppTracker::cgroupsChanged(const QString& directory) {
QDir dir(directory);
QDir mountpoint(s_cgroupMount);
QDir mountpoint(m_cgroupMount);
QFileInfoList newScopes = dir.entryInfoList(
QStringList{"*.scope", "*@autostart.service"}, QDir::Dirs);
QStringList oldScopes = m_runningApps.keys();
QStringList oldScopes = m_runningCgroups.keys();

// Figure out what has been added.
for (const QFileInfo& scope : newScopes) {
Expand All @@ -172,46 +232,22 @@ void AppTracker::cgroupsChanged(const QString& directory) {
if (oldScopes.removeAll(path) == 0) {
// This is a new scope, let's add it.
logger.debug() << "Control group created:" << path;
AppData* data = new AppData(path);

m_runningApps[path] = data;
appHeuristicMatch(data);
QString desktopFileId = findDesktopFileId(path);
m_runningCgroups[path] = desktopFileId;

emit appLaunched(data->cgroup, data->appId, data->rootpid);
emit appLaunched(path, desktopFileId);
}
}

// Anything left, if it shares the same root directory, has been removed.
for (const QString& scope : oldScopes) {
QFileInfo scopeInfo(s_cgroupMount + scope);
QFileInfo scopeInfo(m_cgroupMount + scope);
if (scopeInfo.absolutePath() == directory) {
logger.debug() << "Control group removed:" << scope;
Q_ASSERT(m_runningApps.contains(scope));
AppData* data = m_runningApps.take(scope);
Q_ASSERT(m_runningCgroups.contains(scope));
QString desktopFileId = m_runningCgroups.take(scope);

emit appTerminated(data->cgroup, data->appId);
delete data;
emit appTerminated(scope, desktopFileId);
}
}
}

QList<int> AppData::pids() const {
QList<int> results;
QFile cgroupProcs(s_cgroupMount + cgroup + "/cgroup.procs");

if (cgroupProcs.open(QIODevice::ReadOnly | QIODevice::Text)) {
while (true) {
QString line = QString::fromLocal8Bit(cgroupProcs.readLine());
if (line.isEmpty()) {
break;
}
int pid = line.trimmed().toInt();
if (pid != 0) {
results.append(pid);
}
}
cgroupProcs.close();
}

return results;
}
84 changes: 58 additions & 26 deletions src/platforms/linux/daemon/apptracker.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,31 @@

class QDBusInterface;

class AppData {
public:
AppData(const QString& path) : cgroup(path) { MZ_COUNT_CTOR(AppData); }
~AppData() { MZ_COUNT_DTOR(AppData); }

QList<int> pids() const;

const QString cgroup;
QString appId;
int rootpid = 0;
};

// Applications on Linux can be a bit vague and hard to define at runtime, so
// we need to make some assumptions to try and tackle the problem.
//
// First off, we try to identify applications based on the application menu
// entries. According to the Freedesktop specification, these are described by
// the `*.desktop` files found under the user's XDG_DATA_DIRS environment
// variable. The Freedesktop specification also defines that the path to this
// file can be converted into a Desktop File ID, which we will use as the
// identifier of the application.
//
// However, the `*.desktop` files only describe how to launch an application.
// Once an application has started, there is no definitive way to track the
// processes of that application. For this, we rely on Linux control groups,
// or cgroups, which are used to group processes together for the purpose of
// establishing shared resource constraints and containerization.
//
// It just so happens that many modern desktop environments will group their
// processes forked from the application launchers into cgroups for resource
// management and containerization. This class attempts to track those cgroups
// and match them to the destop file ID from which they originated.
//
// This means that we only support application environments which make use of
// control groups for application containerization, and apps which can be
// launched via the applications menu. This limits us to Gnome, KDE, Flatpaks,
// and Snaps.
class AppTracker final : public QObject {
Q_OBJECT
Q_DISABLE_COPY_MOVE(AppTracker)
Expand All @@ -33,37 +46,56 @@ class AppTracker final : public QObject {
explicit AppTracker(QObject* parent = nullptr);
~AppTracker();

/**
* @brief Track a new user session for control group scopes where applications
* may be running.
*
* @param userid Unix User identifier.
* @param xdgRuntimePath User's runtime path (eg: "/run/user/<uid>").
*/
void userCreated(uint userid, const QString& xdgRuntimePath);

/**
* @brief Terminate tracking of a user session.
*
* @param userid Unix User identifier.
*/
void userRemoved(uint userid);

QHash<QString, AppData*>::iterator begin() { return m_runningApps.begin(); }
QHash<QString, AppData*>::iterator end() { return m_runningApps.end(); }
AppData* find(const QString& cgroup) { return m_runningApps.value(cgroup); }
/**
* @brief Return a list of control groups matching a given desktop file ID.
*
* @param desktopFileId desktop file ID to match.
*
* @returns a list control group scopes.
*/
QStringList findByDesktopFileId(const QString& desktopFileId) const {
return m_runningCgroups.keys(desktopFileId);
}

signals:
void appLaunched(const QString& cgroup, const QString& appId, int rootpid);
void appTerminated(const QString& cgroup, const QString& appId);
void appLaunched(const QString& cgroup, const QString& desktopFileId);
void appTerminated(const QString& cgroup, const QString& desktopFileId);

private slots:
void gtkLaunchEvent(const QByteArray& appid, const QString& display,
qlonglong pid, const QStringList& uris,
const QVariantMap& extra);

void cgroupsChanged(const QString& directory);

private:
void appHeuristicMatch(AppData* data);
QString findDesktopFileId(const QString& cgroup);
static QString snapDesktopFileId(const QString& cgroup);
static QString decodeUnicodeEscape(const QString& str);

private:
// Monitoring of the user's control groups.
QString m_cgroupMount;
QFileSystemWatcher m_cgroupWatcher;
QDBusInterface* m_systemdInterface = nullptr;

// The set of applications that we have tracked.
QHash<QString, AppData*> m_runningApps;
QString m_lastLaunchName;
int m_lastLaunchPid;
// The set of control groups that are currently running, and the desktop file
// IDs to which we have mapped them. The key to this QHash is the control
// group path, and the value is the mapped desktop file ID, or an empty
// QString if unknown.
QHash<QString, QString> m_runningCgroups;
};

#endif // APPTRACKER_H
Loading

0 comments on commit caf70fc

Please sign in to comment.