Files
isolationRunner/IsolationManager.cpp
tomFlowee c0f579ff6d add VPN feature
This allows a jail to have a VPN config associated and as a result we start
a new net namespace, completely isolating the jails networking.
We then start an openVPN client to route between the main network and the
jails' network.

The main limitation here is that we don't setup DNS, which basically means
that the VPN will route DNS calls to the other side, but since we don't
remount resolv.conf this depends on the vpn provider actually mapping the
nameserver we use. For people that use a nameserver like 192.168.100.1,
this most of the time works just fine.

Improvement is possible.
2026-04-11 15:06:44 +02:00

691 lines
24 KiB
C++

#include "Message.h"
#include "IsolationManager.h"
#include <unistd.h>
#include <sys/stat.h> // for umask
#include <QDebug>
#include <QDir>
#include <QDirIterator>
#include <QFileInfo>
#include <QSettings>
#include <QStringBuilder>
#include <QStandardPaths>
#include <QTimer>
#include <QInputDialog>
#include <QCryptographicHash>
#include <QBuffer>
namespace {
QByteArray computeFileHash(QFile &file)
{
QCryptographicHash hasher(QCryptographicHash::Md5);
constexpr qint64 CHUNK_SIZE = 64 * 1024;
while (!file.atEnd()) {
const QByteArray chunk = file.read(CHUNK_SIZE);
if (chunk.isEmpty())
break;
hasher.addData(chunk);
}
return hasher.result();
};
bool copyIfDifferent(const QString &source, const QString &target)
{
QFile in(source);
if (!in.open(QIODevice::ReadOnly))
return false;
QFile out(target);
if (out.open(QIODevice::ReadOnly)) {
const QByteArray sourceHash = computeFileHash(in);
assert(!sourceHash.isEmpty()); // crypto failure..
const QByteArray targetHash = computeFileHash(out);
if (!sourceHash.isEmpty() && sourceHash == targetHash)
return true;
}
in.close();
QFile::remove(target);
bool ok = QFile::copy(source, target);
if (ok)
out.setPermissions(QFileDevice::ReadOwner);
out.close();
return ok;
}
}
class RulesError : public std::runtime_error
{
public:
RulesError(const char *message, const QString &filename_ = QString())
: std::runtime_error(message),
filename(filename_)
{
}
const QString filename;
};
IsolationManager::IsolationManager(int inputId, int outputId)
: m_runner(inputId, outputId),
m_listener(this),
m_rulesDir("/etc/security/iso-rules/"),
m_dbdir("1/db/")
{
setObjectName(QLatin1String("IsolationManager")); // For the DBus RPC
m_basedir = QDir::currentPath() % "/";
umask(077); // All we create will only be readable by the owner.
QDir basedir(m_basedir);
// Qt bases this on $HOME, so lets make sure we are not being played here.
if (!basedir.isAbsolute())
throw std::runtime_error("Config error: datadir has to be absolute");
QDirIterator jailChecker(".");
while (jailChecker.hasNext()) {
auto entry = jailChecker.next().mid(2); // snip off the "./"
bool ok;
int jid = entry.toInt(&ok);
if (ok && jid >= m_nextJailId)
m_nextJailId = jid + 1;
}
connect (&m_runner, SIGNAL(receivedMessage(QByteArray)),
this, SLOT(receivedMessageFromRunner(QByteArray)));
}
QString IsolationManager::startApplicationRequest(AppEntry &dbEntry, const QStringList &arguments)
{
assert(dbEntry.appId > 1);
/*
* Each app gets its own subdir under m_basedir using its appid as name.
*/
QDir base(m_basedir);
QString homedir = QString::number(dbEntry.appId);
if (!base.mkpath(homedir))
return QString("Internal error: failed to create environment");
if (dbEntry.jailPassword.isEmpty()
&& QFile::exists(m_basedir % "/." % homedir % "/.encfs6.xml")) {
auto *da = new DelayedApp(dbEntry, arguments, this);
da->askPassword();
return QString();
}
// then ask the priviledged task to take it from here.
Message message(Message::MAX_SIZE);
message.setJailId(dbEntry.appId);
try {
if (!dbEntry.jailPassword.isEmpty())
message.setJailPassword(dbEntry.jailPassword.toStdString());
QString exe = dbEntry.pathToExe;
// resolve symlinks and actually find the real executable.
for (int i = 0; i < 10; ++i) { // avoid endless loops
QFileInfo info(exe);
if (info.isSymbolicLink()) {
exe = info.symLinkTarget();
} else {
break;
}
}
// The user should be able to start executables that are stored in the
// homedir, except that the path to that is going to be different in
// his little jail. Lets special case that...
if (exe.startsWith(QDir::homePath())) {
QString newPath = QDir::homePath();
const auto length = newPath.size();
exe = newPath + QString("/shared%1").arg(exe.mid(length));
}
message.setPath(exe.toStdString());
for (const auto &s : arguments) {
message.addArgument(s.toUtf8().constData());
}
// VPN crap
if (!dbEntry.vpnConf.isEmpty()) {
const QString vpnHomeBase = expandVars(dbEntry, QString("$APPHOME/config/vpn"));
QDir(vpnHomeBase).mkpath(".");
const QString appHomeDir = vpnHomeBase + "/.vpn";
if (!copyIfDifferent(dbEntry.vpnConf, appHomeDir + ".ovpn"))
return "vpn config file not found";
message.setVpnConfig(expandVars(dbEntry, QString("$HOME/config/vpn/.vpn")).toStdString());
if (!dbEntry.vpnAc.isEmpty()) {
message.setHasVpnPassFile(true);
QFileInfo orig(dbEntry.vpnAc);
if (!orig.exists())
return "vpn access controls config file not found";
QFileInfo copy(appHomeDir + "ac");
if (!copy.exists() || orig.lastModified() > copy.lastModified()) {
// we still call copyIfDifferent because lazyness. But as its made only
// readable by root, when we get here, we will do the copy.
if (!copyIfDifferent(dbEntry.vpnAc, appHomeDir + "ac"))
return "vpn AC file can't be copied";
}
}
QBuffer script;
script.open(QIODevice::WriteOnly);
const auto netNsName = QString("iso-jail%1").arg(dbEntry.appId);
script.write("#!/bin/sh\n"
"# OpenVPN passes: $1 = dev, $2 = mtu, $4 = local IP, etc.\n\n");
script.write("myNetNsName=\"");
script.write(netNsName.toLatin1());
script.write("\"\n");
script.write("case \"$script_type\" in\n"
" up)\n"
" if ip netns exec $myNetNsName ip link show $1 >/dev/null 2>&1\n"
" then\n"
// for when it comes up again after being down
" ip netns exec $myNetNsName ip link up $1\n"
" else\n"
" dev=$1; mtu=$2\n"
/* The script is called by openvpn on 'up', which seems to be very much not when it's up.
* So in the script we create a new script that copies all the passed in parameters
* but starts with a while loop checking if the device is up yet. Which may take some seconds.
* Only when it's up do we then we run our networking code to move to the namespace, and
* configure the new interface.
*/
" echo \"i=0; while [ \\$i -lt 10 ]; do ip link show $dev >/dev/null 2>&1"
" && { break; }"
" || { i=\\$((i+1)); sleep 0.5; }; done; [ \\$i -eq 10 ] && exit\" >>/tmp/vpnconf.sh\n"
" echo ip link set dev $dev up netns $myNetNsName mtu $mtu >> /tmp/vpnconf.sh\n"
" echo ip netns exec $myNetNsName ip addr add dev $dev \"$4/${ifconfig_netmask:-30}\" "
"${ifconfig_broadcast:+broadcast \"$ifconfig_broadcast\"} >> /tmp/vpnconf.sh\n"
" echo ip netns exec $myNetNsName ip route add default via \"$route_vpn_gateway\" "
"dev $dev >> /tmp/vpnconf.sh\n"
" chmod 700 /tmp/vpnconf.sh\n"
" fi\n"
" ;;\n"
" down)\n"
" ip netns exec $myNetNsName ip link set dev \"$dev\" down 2>/dev/null || true\n"
" ;;\n"
"esac\n");
script.close();
QFile target(appHomeDir + ".sh");
bool same = false;
const auto &scriptBytes = script.buffer();
if (target.open(QIODevice::ReadOnly)) {
QCryptographicHash hasher(QCryptographicHash::Md5);
hasher.addData(scriptBytes);
const QByteArray sourceHash = hasher.result();
assert(!sourceHash.isEmpty()); // crypto failure..
const QByteArray targetHash = computeFileHash(target);
same = targetHash == sourceHash;
target.close();
}
if (!same) {
QFile::remove(appHomeDir + ".sh");
bool ok = target.open(QIODevice::WriteOnly);
assert(ok);
target.write(scriptBytes);
target.close();
target.setPermissions(QFileDevice::ReadOwner | QFileDevice::ExeOwner);
}
}
applyRules(dbEntry, message, m_rulesDir + "global.rules");
QFileInfo proxy("/bin/xdg-dbus-proxy");
if (proxy.isFile() && proxy.isExecutable()) {
if (dbEntry.isAllowed("dbus")) {
QString target = expandVars(dbEntry, QLatin1String("/run/user/$USERID/bus"));
message.addDBusProxy(Message::UserSessionBus, "unix:path=/run/dbus/user-global",
target.toStdString());
}
if (dbEntry.isAllowed("dbus-system"))
message.addDBusProxy(Message::SystemBus, "unix:path=/run/dbus/system-global",
"/run/dbus/system_bus_socket");
}
else {
qWarning() << "Missing tool 'xdg-dbus-proxy', please install to provide dbus to jailed apps";
}
if (!dbEntry.initScript.isEmpty())
message.addInitSript(dbEntry.initScript.toStdString());
} catch (const RulesError &e) {
if (e.filename.isEmpty())
return QString(e.what());
return QString("%1 '%2'").arg(e.what(), e.filename);
} catch (const std::exception &e) {
return QString("Limits reached");
}
const QString stateInfo = m_basedir % "/1/state-" % QString::number(dbEntry.appId) % ".info";
QSettings runInfo(stateInfo, QSettings::IniFormat);
runInfo.setValue("start", QDateTime::currentDateTimeUtc());
runInfo.remove("error"); // in case we are re-using an old state file.
if (dbEntry.autoDelete) {
runInfo.setValue("autodelete", true);
new AutoDeleter(dbEntry, this);
}
m_runner.runRemote(message);
// Because it is useful, lets point from the profile name to the jail directory
// (a boring int), if it doesn't exist yet.
if (!dbEntry.autoDelete) {
QFileInfo symlink(m_basedir + dbEntry.profileName);
if (!symlink.isSymLink() && !symlink.exists()) {
QFile jail(m_basedir + homedir);
assert(jail.exists());
jail.link(dbEntry.profileName);
}
}
return QString("ok");
}
IsolationManager::AppEntry IsolationManager::lookupApp(const QString &profileName, LookupBehavior behavior)
{
IsolationManager::AppEntry rc;
auto entry = startEditApp(profileName, behavior);
rc.profileName = entry->property("profileName").toString();
rc.appId = entry->value("app-id", -1).toInt();
if (rc.appId == -1) {
assert(behavior == OnlyExisting);
return rc;
}
auto denied = entry->value("denied").toString();
rc.denied = denied.split(' ', Qt::SkipEmptyParts);
auto allowed = entry->value("allowed").toString();
rc.allowed = allowed.split(' ', Qt::SkipEmptyParts);
rc.pathToExe = entry->value("path-to-exe", rc.profileName).toString();
rc.initScript = entry->value("init-script", QString()).toString();
rc.vpnAc = entry->value("vpnAc", QString()).toString();
rc.vpnConf = entry->value("vpnConf", QString()).toString();
return rc;
}
std::unique_ptr<QSettings> IsolationManager::startEditApp(const QString &profileName, LookupBehavior behavior)
{
// this removes some stuff, it makes 'firefox' and '/bin/firefox' point to the same database entry.
QString shortName(profileName);
if (shortName.startsWith("/bin/"))
shortName = shortName.mid(5);
else if (shortName.startsWith("/usr/bin/"))
shortName = shortName.mid(9);
else if (shortName.startsWith("/"))
shortName = shortName.mid(1);
shortName.replace('%', "%25");
shortName.replace('/', "%2f");
auto entry = std::make_unique<QSettings>(m_basedir % m_dbdir % "/" % shortName % ".info", QSettings::IniFormat);
entry->setProperty("profileName", shortName);
if (entry->value("app-id", -1).toInt() == -1) { // did not exist
if (behavior == OnlyExisting)
return entry;
// ok, then create a new one with a fresh id.
entry->setValue("app-id", m_nextJailId++);
}
return entry;
}
QList<IsolationManager::ProfileInfo> IsolationManager::listProfiles() const
{
QList<ProfileInfo> answer;
QDirIterator iter(m_basedir % m_dbdir);
while (iter.hasNext()) {
if (!iter.next().endsWith(".info"))
continue;
const auto infoFile = iter.fileName();
ProfileInfo pi;
pi.name = infoFile.left(infoFile.size() - 5); // filename, but chop off the extension
pi.name.replace("%2f", "/"); // reverse internal encoding
pi.name.replace("%%", "%");
QSettings entry(iter.filePath(), QSettings::IniFormat);
pi.jailId = entry.value("app-id", 0).toInt();
if (pi.jailId > 1) { // ignore invalid profile entries
pi.exe = entry.value("path-to-exe").toString();
QString appStateFile = stateFile(pi.jailId);
if (QFile::exists(appStateFile)) {
QSettings appState(appStateFile, QSettings::IniFormat);
pi.lastRun = appState.value("start").toDateTime();
int pid = appState.value("pid").toInt();
if (pid)
pi.active = QFile::exists(QString("/proc/%1").arg(pid));
}
answer.append(pi);
}
}
return answer;
}
QDir IsolationManager::dbDir() const
{
return QDir(m_basedir % m_dbdir);
}
QString IsolationManager::stateFile(int jailId) const
{
return QString (m_basedir % "/1/state-" % QString::number(jailId) % ".info");
}
QString IsolationManager::pipeFilePath(int jailId) const
{
return QString(m_basedir % QString::number(jailId) % "/.iso-pipe");
}
QString IsolationManager::jailDir(int jailId) const
{
return QString(m_basedir % QString::number(jailId));
}
void IsolationManager::applyRules(AppEntry &context, Message &message, const QString &ruleFile) const
{
QFile in(ruleFile);
if (!in.open(QIODevice::ReadOnly))
throw RulesError("Rules could not be read", ruleFile);
bool inIfBlock = false;
bool ifWasTrue = false;
int lineNum = 0;
for (const QString line_ : in.readAll().split('\n')) {
auto line = line_.trimmed();
++lineNum;
if (line.isEmpty() || line.startsWith('#'))
continue;
auto items = line.split(' ', Qt::SkipEmptyParts);
assert(!items.isEmpty());
// the try concept means that commands failing are seen as a problem
// and we stop the processing and fail to start the application if the
// command didn't succeed.
bool isATry = false;
if (items.at(0) == "try") {
items.takeFirst();
isATry = true;
}
if (inIfBlock) {
if (items.size() >= 2 && items.at(0) == "if")
throw RulesError("nested if detected", ruleFile);
if (items.size() == 1 && items.at(0) == "endif") {
inIfBlock = false;
continue;
}
if (!ifWasTrue)
continue;
} else if (items.at(0) == "endif") {
throw RulesError("endif without if found");
}
// bind takes 2 arguments.
if (items.size() == 3 && items.at(0) == "bind") {
message.setTry(isATry);
message.addRemount(
expandVars(context, items.at(1)).toStdString(),
expandVars(context, items.at(2)).toStdString());
}
else if (items.size() == 2 && items.at(0) == "umount") {
message.setTry(isATry);
message.addUmountPoint(expandVars(context, items.at(1)).toStdString());
}
else if (items.size() == 2 && items.at(0) == "tmpfs") {
message.setTry(isATry);
message.addMountTmpDir(expandVars(context, items.at(1)).toStdString());
}
else if (items.size() == 3 && items.at(0) == "copy") {
message.setTry(isATry);
message.addCopy(
expandVars(context, items.at(1)).toStdString(),
expandVars(context, items.at(2)).toStdString());
}
else if (items.size() == 2 && items.at(0) == "setEnv") {
if (isATry)
throw RulesError("try not allowed in front of setEnv");
auto var = expandVars(context, items.at(1));
if (var.indexOf('=') == -1)
throw RulesError("setEnv arg needs to be of name=value");
message.addEnvToSet(var.toStdString());
}
else if (items.size() == 3 && items.at(0) == "setPermissionDefault") {
if (!context.isKnownPermission(items.at(1)))
throw RulesError("unknown permission named");
if (items.at(2) != "allowed" && items.at(2) != "denied")
throw RulesError("default permission unknown. Use allowed or denied.");
context.defaults[items.at(1)] = (items.at(2) == "allowed");
}
else if (items.size() == 2 && items.at(0) == "unsetEnv") {
if (isATry)
throw RulesError("try not allowed in front of setEnv");
auto var = expandVars(context, items.at(1));
if (var.indexOf('=') != -1)
throw RulesError("unsetEnv arg can not have a '=' sign");
message.addEnvToUnset(var.toStdString());
}
else if (items.size() >= 2 && items.at(0) == "if") {
bool negative = false;
int index = 2;
if (items.at(1) == "denied") {
negative = true;
} else if (items.at(1) != "allowed") {
index = 1;
}
if (items.size() != index + 1)
throw RulesError("malformed if statement", ruleFile);
inIfBlock = true;
ifWasTrue = context.isAllowed(items.at(index));
if (negative) // invert result
ifWasTrue = !ifWasTrue;
}
else if (items.size() == 1 && items.at(0) == "execute-apprules") {
QString rules = m_rulesDir + context.profileName + ".rules";
// also check we're not recursing.
// first check if we have an app-specific one.
if (rules == ruleFile || !QFileInfo::exists(rules))
rules = m_rulesDir + "default.rules";
if (rules == ruleFile)
throw RulesError("Recursive execute-apprules found");
applyRules(context, message, rules);
}
else {
QString a = QString("Rule-parsing failure on line %1").arg(lineNum);
std::string latin1(a.toStdString());
throw RulesError(latin1.c_str(), ruleFile);
}
}
}
QString IsolationManager::expandVars(const AppEntry &context, const QString &path_) const
{
QString path(path_);
int index = path.indexOf("$APPHOME");
if (index != -1
&& (index <= 0 || path.at(index - 1) != '\\')) {
path = path.left(index) + QDir::homePath()
+ QString("/.local/jails/%1").arg(context.appId)
+ path.mid(index + 8);
}
index = path.indexOf("$HOME");
if (index != -1
&& (index <= 0 || path.at(index - 1) != '\\')) {
path = path.left(index) + QDir::homePath() + path.mid(index + 5);
}
index = path.indexOf("$USERID");
if (index != -1
&& (index <= 0 || path.at(index - 1) != '\\')) {
path = path.left(index) + QString::number(getuid()) + path.mid(index + 7);
}
index = path.indexOf("$JAILID");
if (index != -1
&& (index <= 0 || path.at(index - 1) != '\\')) {
path = path.left(index) + QString::number(context.appId) + path.mid(index + 7);
}
return path;
}
QString IsolationManager::rulesDir() const
{
return m_rulesDir;
}
void IsolationManager::setRulesDir(const QString &dir)
{
m_rulesDir = dir;
if (!m_rulesDir.endsWith('/'))
m_rulesDir += "/";
}
void IsolationManager::receivedMessageFromRunner(const QByteArray &data)
{
QString line_ = QString::fromLatin1(data);
QStringView line(line_);
int index = line.indexOf(' ');
if (index == -1) {
qWarning() << "Malformed message from runner";
return;
}
bool ok;
const int appId = line.left(index).toInt(&ok);
if (!ok || appId <= 1) {
qWarning() << "Malformed message from runner";
return;
}
const QString stateInfoFilename = stateFile(appId);
QSettings runInfo(stateInfoFilename, QSettings::IniFormat);
auto rest = line.mid(index + 1);
const auto pid = rest.toLong();
if (pid) {
runInfo.setValue("pid", rest.toString());
} else {
runInfo.setValue("error", rest.toString());
}
}
bool IsolationManager::AppEntry::isAllowed(const QString &tag) const
{
auto iter = defaults.find(tag);
bool defaultAnswer = true;
if (iter != defaults.end())
defaultAnswer = *iter;
if (defaultAnswer && denied.contains(tag))
return false;
if (!defaultAnswer && allowed.contains(tag))
return true;
return defaultAnswer;
}
void IsolationManager::AppEntry::setDenied(const QStringList &entries)
{
denied.clear();
for (const auto &perm : entries) {
if (!isKnownPermission(perm))
continue;
denied.append(perm);
}
}
void IsolationManager::AppEntry::setAllowed(const QStringList &entries)
{
allowed.clear();
for (const auto &perm : entries) {
if (!isKnownPermission(perm))
continue;
allowed.append(perm);
}
}
bool IsolationManager::AppEntry::isKnownPermission(const QString &perm) const
{
if (perm == "ssh" || perm == "git" || perm == "homedir" || perm == "media"
|| perm == "dbus" || perm == "dbus-system" || perm == "audio"
|| perm == "docker")
return true;
return false;
}
AutoDeleter::AutoDeleter(IsolationManager::AppEntry appEntry, IsolationManager *parent)
: QObject(parent),
m_parent(parent),
m_jail(appEntry)
{
assert(parent);
assert(m_jail.appId > 1);
// wait a little while, as it may take a couple more milliseconds
// to actually create the pipe.
QTimer::singleShot(1, this, SLOT(startMonitor()));
connect (&m_watcher, SIGNAL(fileChanged(QString)),
this, SLOT(jailClosed(QString)));
}
void AutoDeleter::startMonitor()
{
++m_try;
bool ok = m_watcher.addPath(m_parent->pipeFilePath(m_jail.appId));
if (!ok) {
// it fails if the file isn't there. Likely due to us trying
// too fast and the child process hasn't created the pipe yet.
if (m_try > 10) {
deleteLater();
return;
}
// try again soon.
QTimer::singleShot(5 * m_try, this, SLOT(startMonitor()));
}
}
void AutoDeleter::jailClosed(const QString &pipeFile)
{
bool ok = QFile::remove(m_parent->dbDir().absoluteFilePath(m_jail.profileName + ".info"));
if (!ok)
qWarning() << "Auto-remove: Failed to remove db file";
ok = QFile::remove(m_parent->stateFile(m_jail.appId));
if (!ok)
qWarning() << "Auto-remove: Failed to remove state";
QDir jailDir(m_parent->jailDir(m_jail.appId));
ok = jailDir.removeRecursively();
if (!ok)
qWarning() << "Auto-remove: Failed to remove jail-dir";
deleteLater();
}
DelayedApp::DelayedApp(IsolationManager::AppEntry appEntry, const QStringList &arguments, IsolationManager *parent)
: m_parent(parent),
m_jail(appEntry),
m_arguments(arguments)
{
assert(parent);
}
void DelayedApp::askPassword()
{
auto id = new QInputDialog();
m_win = id;
id->setInputMode(QInputDialog::TextInput);
id->setTextEchoMode(QLineEdit::Password);
id->setLabelText("ISO password:");
id->setWindowTitle("Jail Requires Password");
id->open(this, SLOT(passwordEntered(QString)));
connect (id, SIGNAL(rejected()), this, SLOT(cancelPressed()));
}
void DelayedApp::cancelPressed()
{
qWarning() << "canceled";
m_win->deleteLater();
deleteLater();
}
void DelayedApp::passwordEntered(const QString &text)
{
m_win->deleteLater();
m_jail.jailPassword = text;
m_parent->startApplicationRequest(m_jail, m_arguments);
deleteLater();
}