forked from brl/citadel-tools
Support for flatpak and GNOME Software in Realms
When a realm has enabled 'use-flatpak' a .desktop file for GNOME Software will be automatically generated while that realm is running. This .desktop file will launch GNOME Software from Citadel inside a bubblewrap sandbox. The sandbox has been prepared so that GNOME Software will install flatpak applications into a directory that belongs to the realm associated with the .desktop file. When a realm has enabled 'use-flatpak' this directory will be bind mounted (read-only) into the root filesystem of the realm so that applications installed by GNOME Software are visible and can be launched.
This commit is contained in:
parent
2a16bd4c41
commit
2dc8bf2922
991
Cargo.lock
generated
991
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["citadel-realms", "citadel-installer-ui", "citadel-tool", "realmsd", "realm-config-ui" ]
|
members = ["citadel-realms", "citadel-installer-ui", "citadel-tool", "realmsd", "realm-config-ui", "launch-gnome-software" ]
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
@ -99,7 +99,7 @@ impl DesktopFileSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn collect_source_files(&mut self, directory: impl AsRef<Path>) -> Result<()> {
|
fn collect_source_files(&mut self, directory: impl AsRef<Path>) -> Result<()> {
|
||||||
let mut directory = Realms::current_realm_symlink().join(directory.as_ref());
|
let mut directory = self.realm.run_path().join(directory.as_ref());
|
||||||
directory.push("share/applications");
|
directory.push("share/applications");
|
||||||
if directory.exists() {
|
if directory.exists() {
|
||||||
util::read_directory(&directory, |dent| {
|
util::read_directory(&directory, |dent| {
|
||||||
@ -126,7 +126,11 @@ impl DesktopFileSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn remove_missing_target_files(&mut self) -> Result<()> {
|
fn remove_missing_target_files(&mut self) -> Result<()> {
|
||||||
let sources = self.source_filenames();
|
let mut sources = self.source_filenames();
|
||||||
|
// If flatpak is enabled, don't remove the generated GNOME Software desktop file
|
||||||
|
if self.realm.config().flatpak() {
|
||||||
|
sources.insert(format!("realm-{}.org.gnome.Software.desktop", self.realm.name()));
|
||||||
|
}
|
||||||
let prefix = format!("realm-{}.", self.realm.name());
|
let prefix = format!("realm-{}.", self.realm.name());
|
||||||
util::read_directory(Self::CITADEL_APPLICATIONS, |dent| {
|
util::read_directory(Self::CITADEL_APPLICATIONS, |dent| {
|
||||||
if let Some(filename) = dent.file_name().to_str() {
|
if let Some(filename) = dent.file_name().to_str() {
|
||||||
@ -182,7 +186,9 @@ impl DesktopFileSync {
|
|||||||
|
|
||||||
fn sync_item(&self, item: &DesktopItem) -> Result<()> {
|
fn sync_item(&self, item: &DesktopItem) -> Result<()> {
|
||||||
let mut dfp = DesktopFileParser::parse_from_path(&item.path, "/usr/libexec/citadel-run ")?;
|
let mut dfp = DesktopFileParser::parse_from_path(&item.path, "/usr/libexec/citadel-run ")?;
|
||||||
if dfp.is_showable() {
|
// When use-flatpak is enabled a gnome-software desktop file will be generated
|
||||||
|
let flatpak_gs_hide = dfp.filename() == "org.gnome.Software.desktop" && self.realm.config().flatpak();
|
||||||
|
if dfp.is_showable() && !flatpak_gs_hide {
|
||||||
self.sync_item_icon(&mut dfp);
|
self.sync_item_icon(&mut dfp);
|
||||||
dfp.write_to_dir(Self::CITADEL_APPLICATIONS, Some(&self.realm))?;
|
dfp.write_to_dir(Self::CITADEL_APPLICATIONS, Some(&self.realm))?;
|
||||||
} else {
|
} else {
|
||||||
|
@ -4,7 +4,6 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use libcitadel::{Result, util, Realm};
|
use libcitadel::{Result, util, Realm};
|
||||||
use std::cell::{RefCell, Cell};
|
use std::cell::{RefCell, Cell};
|
||||||
use std::fs;
|
|
||||||
use crate::sync::desktop_file::DesktopFile;
|
use crate::sync::desktop_file::DesktopFile;
|
||||||
use crate::sync::REALM_BASE_PATHS;
|
use crate::sync::REALM_BASE_PATHS;
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ fn has_arg(args: &[String], arg: &str) -> bool {
|
|||||||
|
|
||||||
pub const REALM_BASE_PATHS:&[&str] = &[
|
pub const REALM_BASE_PATHS:&[&str] = &[
|
||||||
"rootfs/usr",
|
"rootfs/usr",
|
||||||
"rootfs/var/lib/flatpak/exports",
|
"flatpak/exports",
|
||||||
"home/.local",
|
"home/.local",
|
||||||
"home/.local/share/flatpak/exports"
|
"home/.local/share/flatpak/exports"
|
||||||
];
|
];
|
||||||
|
8
launch-gnome-software/Cargo.toml
Normal file
8
launch-gnome-software/Cargo.toml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "launch-gnome-software"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libcitadel = { path = "../libcitadel" }
|
||||||
|
anyhow = "1.0"
|
67
launch-gnome-software/src/main.rs
Normal file
67
launch-gnome-software/src/main.rs
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
use std::env;
|
||||||
|
|
||||||
|
use libcitadel::{Logger, LogLevel, Realm, Realms, util};
|
||||||
|
use libcitadel::flatpak::GnomeSoftwareLauncher;
|
||||||
|
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
|
||||||
|
|
||||||
|
fn realm_arg() -> Option<String> {
|
||||||
|
let mut args = env::args();
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
if arg == "--realm" {
|
||||||
|
return args.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn choose_realm() -> Result<Realm> {
|
||||||
|
let mut realms = Realms::load()?;
|
||||||
|
if let Some(realm_name) = realm_arg() {
|
||||||
|
match realms.by_name(&realm_name) {
|
||||||
|
None => bail!("realm '{}' not found", realm_name),
|
||||||
|
Some(realm) => return Ok(realm),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let realm = match realms.current() {
|
||||||
|
Some(realm) => realm,
|
||||||
|
None => bail!("no current realm"),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(realm)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_arg(arg: &str) -> bool {
|
||||||
|
env::args()
|
||||||
|
.skip(1)
|
||||||
|
.any(|s| s == arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launch() -> Result<()> {
|
||||||
|
let realm = choose_realm()?;
|
||||||
|
if !util::is_euid_root() {
|
||||||
|
bail!("Must be run with root euid");
|
||||||
|
}
|
||||||
|
let mut launcher = GnomeSoftwareLauncher::new(realm)?;
|
||||||
|
|
||||||
|
if has_arg("--quit") {
|
||||||
|
launcher.quit()?;
|
||||||
|
} else {
|
||||||
|
if has_arg("--shell") {
|
||||||
|
launcher.set_run_shell();
|
||||||
|
}
|
||||||
|
launcher.launch()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
if has_arg("--verbose") {
|
||||||
|
Logger::set_log_level(LogLevel::Verbose);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = launch() {
|
||||||
|
eprintln!("Error: {}", err);
|
||||||
|
}
|
||||||
|
}
|
@ -10,6 +10,7 @@ nix = "0.17.0"
|
|||||||
toml = "0.5"
|
toml = "0.5"
|
||||||
serde = "1.0"
|
serde = "1.0"
|
||||||
serde_derive = "1.0"
|
serde_derive = "1.0"
|
||||||
|
serde_json = "=1.0.1"
|
||||||
lazy_static = "1.4"
|
lazy_static = "1.4"
|
||||||
sodiumoxide = "0.2"
|
sodiumoxide = "0.2"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
221
libcitadel/src/flatpak/bubblewrap.rs
Normal file
221
libcitadel/src/flatpak/bubblewrap.rs
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::{fs, io};
|
||||||
|
use std::fs::File;
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use crate::{Logger, LogLevel, Result, verbose};
|
||||||
|
|
||||||
|
const BWRAP_PATH: &str = "/usr/libexec/flatpak-bwrap";
|
||||||
|
|
||||||
|
pub struct BubbleWrap {
|
||||||
|
command: Command,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BubbleWrap {
|
||||||
|
|
||||||
|
pub fn new() -> Self {
|
||||||
|
BubbleWrap {
|
||||||
|
command: Command::new(BWRAP_PATH),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self {
|
||||||
|
self.command.arg(arg);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_args<S: AsRef<OsStr>>(&mut self, args: &[S]) -> &mut Self {
|
||||||
|
for arg in args {
|
||||||
|
self.add_arg(arg.as_ref());
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ro_bind(&mut self, path_list: &[&str]) -> &mut Self {
|
||||||
|
for &path in path_list {
|
||||||
|
self.add_args(&["--ro-bind", path, path]);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ro_bind_to(&mut self, src: &str, dest: &str) -> &mut Self {
|
||||||
|
self.add_args(&["--ro-bind", src, dest])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bind_to(&mut self, src: &str, dest: &str) -> &mut Self {
|
||||||
|
self.add_args(&["--bind", src, dest])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_dirs(&mut self, dir_list: &[&str]) -> &mut Self {
|
||||||
|
for &dir in dir_list {
|
||||||
|
self.add_args(&["--dir", dir]);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_symlinks(&mut self, links: &[(&str, &str)]) -> &mut Self {
|
||||||
|
for (src,dest) in links {
|
||||||
|
self.add_args(&["--symlink", src, dest]);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mount_dev(&mut self) -> &mut Self {
|
||||||
|
self.add_args(&["--dev", "/dev"])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mount_proc(&mut self) -> &mut Self {
|
||||||
|
self.add_args(&["--proc", "/proc"])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dev_bind(&mut self, path: &str) -> &mut Self {
|
||||||
|
self.add_args(&["--dev-bind", path, path])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_env(&mut self) -> &mut Self {
|
||||||
|
self.add_arg("--clearenv")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_env_list(&mut self, env_list: &[&str]) -> &mut Self {
|
||||||
|
for line in env_list {
|
||||||
|
if let Some((k,v)) = line.split_once("=") {
|
||||||
|
self.add_args(&["--setenv", k, v]);
|
||||||
|
} else {
|
||||||
|
eprintln!("Warning: environment variable '{}' does not have = delimiter. Ignoring", line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unshare_all(&mut self) -> &mut Self {
|
||||||
|
self.add_arg("--unshare-all")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn share_net(&mut self) -> &mut Self {
|
||||||
|
self.add_arg("--share-net")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn log_command(&self) {
|
||||||
|
let mut buffer = String::new();
|
||||||
|
verbose!("{}", BWRAP_PATH);
|
||||||
|
for arg in self.command.get_args() {
|
||||||
|
if let Some(s) = arg.to_str() {
|
||||||
|
if s.starts_with("-") {
|
||||||
|
if !buffer.is_empty() {
|
||||||
|
verbose!(" {}", buffer);
|
||||||
|
buffer.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !buffer.is_empty() {
|
||||||
|
buffer.push(' ');
|
||||||
|
}
|
||||||
|
buffer.push_str(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !buffer.is_empty() {
|
||||||
|
verbose!(" {}", buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status_file(&mut self, status_file: &File) -> &mut Self {
|
||||||
|
// Rust sets O_CLOEXEC when opening files so we create
|
||||||
|
// a new descriptor that will remain open across exec()
|
||||||
|
let dup_fd = unsafe {
|
||||||
|
libc::dup(status_file.as_raw_fd())
|
||||||
|
};
|
||||||
|
if dup_fd == -1 {
|
||||||
|
warn!("Failed to dup() status file descriptor: {}", io::Error::last_os_error());
|
||||||
|
warn!("Skipping --json-status-fd argument");
|
||||||
|
self
|
||||||
|
} else {
|
||||||
|
self.add_arg("--json-status-fd")
|
||||||
|
.add_arg(dup_fd.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn launch<S: AsRef<OsStr>>(&mut self, cmd: &[S]) -> Result<()> {
|
||||||
|
if Logger::is_log_level(LogLevel::Verbose) {
|
||||||
|
self.log_command();
|
||||||
|
let s = cmd.iter().map(|s| format!("{} ", s.as_ref().to_str().unwrap())).collect::<String>();
|
||||||
|
verbose!(" {}", s)
|
||||||
|
}
|
||||||
|
self.add_args(cmd);
|
||||||
|
let err = self.command.exec();
|
||||||
|
bail!("failed to exec bubblewrap: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize,Clone)]
|
||||||
|
#[serde(rename_all="kebab-case")]
|
||||||
|
pub struct BubbleWrapRunningStatus {
|
||||||
|
pub child_pid: u64,
|
||||||
|
pub cgroup_namespace: u64,
|
||||||
|
pub ipc_namespace: u64,
|
||||||
|
pub mnt_namespace: u64,
|
||||||
|
pub pid_namespace: u64,
|
||||||
|
pub uts_namespace: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize,Clone)]
|
||||||
|
#[serde(rename_all="kebab-case")]
|
||||||
|
pub struct BubbleWrapExitStatus {
|
||||||
|
pub exit_code: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct BubbleWrapStatus {
|
||||||
|
running: BubbleWrapRunningStatus,
|
||||||
|
exit: Option<BubbleWrapExitStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BubbleWrapStatus {
|
||||||
|
pub fn parse_file(path: impl AsRef<Path>) -> Result<Option<Self>> {
|
||||||
|
if !path.as_ref().exists() {
|
||||||
|
return Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
let s = fs::read_to_string(path)
|
||||||
|
.map_err(context!("error reading status file"))?;
|
||||||
|
|
||||||
|
let mut lines = s.lines();
|
||||||
|
let running = match lines.next() {
|
||||||
|
None => return Ok(None),
|
||||||
|
Some(s) => serde_json::from_str::<BubbleWrapRunningStatus>(s)
|
||||||
|
.map_err(context!("failed to parse status line ({})", s))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let exit = match lines.next() {
|
||||||
|
None => None,
|
||||||
|
Some(s) => Some(serde_json::from_str::<BubbleWrapExitStatus>(s)
|
||||||
|
.map_err(context!("failed to parse exit line ({})", s))?)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(BubbleWrapStatus {
|
||||||
|
running,
|
||||||
|
exit
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_running(&self) -> bool {
|
||||||
|
self.exit.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn running_status(&self) -> &BubbleWrapRunningStatus {
|
||||||
|
&self.running
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn child_pid(&self) -> u64 {
|
||||||
|
self.running.child_pid
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pid_namespace(&self) -> u64 {
|
||||||
|
self.running.pid_namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn exit_status(&self) -> Option<&BubbleWrapExitStatus> {
|
||||||
|
self.exit.as_ref()
|
||||||
|
}
|
||||||
|
}
|
259
libcitadel/src/flatpak/launcher.rs
Normal file
259
libcitadel/src/flatpak/launcher.rs
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
use std::{fs, io};
|
||||||
|
use std::fs::File;
|
||||||
|
use std::os::unix::fs::FileTypeExt;
|
||||||
|
use std::os::unix::prelude::CommandExt;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use crate::{Realm, Result, util};
|
||||||
|
use crate::flatpak::{BubbleWrap, BubbleWrapStatus, SANDBOX_STATUS_FILE_DIRECTORY, SandboxStatus};
|
||||||
|
use crate::flatpak::netns::NetNS;
|
||||||
|
|
||||||
|
|
||||||
|
const FLATPAK_PATH: &str = "/usr/bin/flatpak";
|
||||||
|
|
||||||
|
const ENVIRONMENT: &[&str; 7] = &[
|
||||||
|
"HOME=/home/citadel",
|
||||||
|
"USER=citadel",
|
||||||
|
"XDG_RUNTIME_DIR=/run/user/1000",
|
||||||
|
"XDG_DATA_DIRS=/home/citadel/.local/share/flatpak/exports/share:/usr/share",
|
||||||
|
"TERM=xterm-256color",
|
||||||
|
"GTK_A11Y=none",
|
||||||
|
"FLATPAK_USER_DIR=/home/citadel/realm-flatpak",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FLATHUB_URL: &str = "https://dl.flathub.org/repo/flathub.flatpakrepo";
|
||||||
|
|
||||||
|
pub struct GnomeSoftwareLauncher {
|
||||||
|
realm: Realm,
|
||||||
|
status: Option<BubbleWrapStatus>,
|
||||||
|
netns: NetNS,
|
||||||
|
run_shell: bool,
|
||||||
|
}
|
||||||
|
impl GnomeSoftwareLauncher {
|
||||||
|
|
||||||
|
pub fn new(realm: Realm) -> Result<Self> {
|
||||||
|
let sandbox_status = SandboxStatus::load(SANDBOX_STATUS_FILE_DIRECTORY)?;
|
||||||
|
let status = sandbox_status.realm_status(&realm);
|
||||||
|
let netns = NetNS::new(NetNS::GS_NETNS_NAME);
|
||||||
|
Ok(GnomeSoftwareLauncher {
|
||||||
|
realm,
|
||||||
|
status,
|
||||||
|
netns,
|
||||||
|
run_shell: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_run_shell(&mut self) {
|
||||||
|
self.run_shell = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_flatpak_dir(&self) -> Result<()> {
|
||||||
|
let flatpak_user_dir = self.realm.base_path_file("flatpak");
|
||||||
|
if !flatpak_user_dir.exists() {
|
||||||
|
if let Err(err) = fs::create_dir(&flatpak_user_dir) {
|
||||||
|
bail!("failed to create realm flatpak directory ({}): {}", flatpak_user_dir.display(), err);
|
||||||
|
}
|
||||||
|
util::chown_user(&flatpak_user_dir)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_flathub(&self) -> Result<()> {
|
||||||
|
let flatpak_user_dir = self.realm.base_path_file("flatpak");
|
||||||
|
|
||||||
|
match Command::new(FLATPAK_PATH)
|
||||||
|
.env("FLATPAK_USER_DIR", flatpak_user_dir)
|
||||||
|
.arg("remote-add")
|
||||||
|
.arg("--user")
|
||||||
|
.arg("--if-not-exists")
|
||||||
|
.arg("flathub")
|
||||||
|
.arg(FLATHUB_URL)
|
||||||
|
.status() {
|
||||||
|
Ok(status) => {
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
|
||||||
|
} else {
|
||||||
|
bail!("failed to add flathub repo")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => bail!("error running flatpak command: {}", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_tmp_directory(path: &Path) -> io::Result<Option<String>> {
|
||||||
|
for entry in fs::read_dir(&path)? {
|
||||||
|
let entry = entry?;
|
||||||
|
if entry.file_type()?.is_socket() {
|
||||||
|
if let Some(filename) = entry.path().file_name() {
|
||||||
|
if let Some(filename) = filename.to_str() {
|
||||||
|
if filename.starts_with("dbus-") {
|
||||||
|
return Ok(Some(format!("/tmp/{}", filename)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_dbus_socket(&self) -> Result<String> {
|
||||||
|
let pid = self.running_pid()?;
|
||||||
|
let tmp_dir = PathBuf::from(format!("/proc/{}/root/tmp", pid));
|
||||||
|
if !tmp_dir.is_dir() {
|
||||||
|
bail!("no /tmp directory found for process pid={}", pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(s) = Self::scan_tmp_directory(&tmp_dir)
|
||||||
|
.map_err(context!("error reading directory {}", tmp_dir.display()))? {
|
||||||
|
Ok(s)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
bail!("no dbus socket found in /tmp directory for process pid={}", pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launch_sandbox(&self, status_file: &File) -> Result<()> {
|
||||||
|
self.ensure_flatpak_dir()?;
|
||||||
|
if let Err(err) = self.netns.nsenter() {
|
||||||
|
bail!("Failed to enter 'gnome-software' network namespace: {}", err);
|
||||||
|
}
|
||||||
|
verbose!("Entered network namespace ({})", NetNS::GS_NETNS_NAME);
|
||||||
|
|
||||||
|
if let Err(err) = util::drop_privileges(1000, 1000) {
|
||||||
|
bail!("Failed to drop privileges to uid = gid = 1000: {}", err);
|
||||||
|
}
|
||||||
|
verbose!("Dropped privileges (uid=1000, gid=1000)");
|
||||||
|
|
||||||
|
self.add_flathub()?;
|
||||||
|
let flatpak_user_dir = self.realm.base_path_file("flatpak");
|
||||||
|
let flatpak_user_dir = flatpak_user_dir.to_str().unwrap();
|
||||||
|
|
||||||
|
let cmd = if self.run_shell { "/usr/bin/bash" } else { "/usr/bin/gnome-software"};
|
||||||
|
verbose!("Running command in sandbox: {}", cmd);
|
||||||
|
|
||||||
|
BubbleWrap::new()
|
||||||
|
.ro_bind(&[
|
||||||
|
"/usr/bin",
|
||||||
|
"/usr/lib",
|
||||||
|
"/usr/libexec",
|
||||||
|
"/usr/share/dbus-1",
|
||||||
|
"/usr/share/icons",
|
||||||
|
"/usr/share/mime",
|
||||||
|
"/usr/share/X11",
|
||||||
|
"/usr/share/glib-2.0",
|
||||||
|
"/usr/share/xml",
|
||||||
|
"/usr/share/drirc.d",
|
||||||
|
"/usr/share/fontconfig",
|
||||||
|
"/usr/share/fonts",
|
||||||
|
"/usr/share/zoneinfo",
|
||||||
|
"/usr/share/swcatalog",
|
||||||
|
|
||||||
|
"/etc/passwd",
|
||||||
|
"/etc/machine-id",
|
||||||
|
"/etc/nsswitch.conf",
|
||||||
|
"/etc/fonts",
|
||||||
|
"/etc/ssl",
|
||||||
|
"/sys/dev/char", "/sys/devices",
|
||||||
|
"/run/user/1000/wayland-0",
|
||||||
|
])
|
||||||
|
.ro_bind_to("/run/NetworkManager/resolv.conf", "/etc/resolv.conf")
|
||||||
|
.bind_to(flatpak_user_dir, "/home/citadel/realm-flatpak")
|
||||||
|
.create_symlinks(&[
|
||||||
|
("usr/lib", "/lib64"),
|
||||||
|
("usr/bin", "/bin"),
|
||||||
|
("/tmp", "/var/tmp"),
|
||||||
|
])
|
||||||
|
.create_dirs(&[
|
||||||
|
"/var/lib/flatpak",
|
||||||
|
"/home/citadel",
|
||||||
|
"/tmp",
|
||||||
|
"/sys/block", "/sys/bus", "/sys/class",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
.mount_dev()
|
||||||
|
.dev_bind("/dev/dri")
|
||||||
|
.mount_proc()
|
||||||
|
.unshare_all()
|
||||||
|
.share_net()
|
||||||
|
|
||||||
|
.clear_env()
|
||||||
|
.set_env_list(ENVIRONMENT)
|
||||||
|
|
||||||
|
.status_file(status_file)
|
||||||
|
|
||||||
|
.launch(&["dbus-run-session", "--", cmd])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_realm_status_file(&self) -> Result<File> {
|
||||||
|
let path = Path::new(SANDBOX_STATUS_FILE_DIRECTORY).join(self.realm.name());
|
||||||
|
File::create(&path)
|
||||||
|
.map_err(context!("failed to open sandbox status file {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn launch(&self) -> Result<()> {
|
||||||
|
self.netns.ensure_exists()?;
|
||||||
|
if self.is_running() {
|
||||||
|
let cmd = if self.run_shell { "/usr/bin/bash" } else { "/usr/bin/gnome-software"};
|
||||||
|
self.launch_in_running_sandbox(&[cmd])?;
|
||||||
|
} else {
|
||||||
|
let status_file = self.new_realm_status_file()?;
|
||||||
|
self.ensure_flatpak_dir()?;
|
||||||
|
self.launch_sandbox(&status_file)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn quit(&self) -> Result<()> {
|
||||||
|
if self.is_running() {
|
||||||
|
self.launch_in_running_sandbox(&["/usr/bin/gnome-software", "--quit"])?;
|
||||||
|
} else {
|
||||||
|
warn!("No running sandbox found for realm {}", self.realm.name());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_running(&self) -> bool {
|
||||||
|
self.status.as_ref()
|
||||||
|
.map(|s| s.is_running())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn running_pid(&self) -> Result<u64> {
|
||||||
|
self.status.as_ref()
|
||||||
|
.map(|s| s.child_pid())
|
||||||
|
.ok_or(format_err!("no sandbox status available for realm '{}',", self.realm.name()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dbus_session_address(&self) -> Result<String> {
|
||||||
|
let dbus_socket = Self::find_dbus_socket(&self)?;
|
||||||
|
Ok(format!("unix:path={}", dbus_socket))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launch_in_running_sandbox(&self, command: &[&str]) -> Result<()> {
|
||||||
|
let dbus_address = self.dbus_session_address()?;
|
||||||
|
|
||||||
|
let pid = self.running_pid()?.to_string();
|
||||||
|
let mut env = ENVIRONMENT.iter()
|
||||||
|
.map(|s| s.split_once('=').unwrap())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
env.push(("DBUS_SESSION_BUS_ADDRESS", dbus_address.as_str()));
|
||||||
|
|
||||||
|
let err = Command::new("/usr/bin/nsenter")
|
||||||
|
.env_clear()
|
||||||
|
.envs( env )
|
||||||
|
.args(&[
|
||||||
|
"--all",
|
||||||
|
"--target", pid.as_str(),
|
||||||
|
"--setuid", "1000",
|
||||||
|
"--setgid", "1000",
|
||||||
|
])
|
||||||
|
.args(command)
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
Err(format_err!("failed to execute nsenter: {}", err))
|
||||||
|
}
|
||||||
|
}
|
16
libcitadel/src/flatpak/mod.rs
Normal file
16
libcitadel/src/flatpak/mod.rs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
pub(crate) mod setup;
|
||||||
|
pub(crate) mod status;
|
||||||
|
|
||||||
|
pub(crate) mod bubblewrap;
|
||||||
|
|
||||||
|
pub(crate) mod launcher;
|
||||||
|
|
||||||
|
pub(crate) mod netns;
|
||||||
|
|
||||||
|
pub use status::SandboxStatus;
|
||||||
|
|
||||||
|
pub use bubblewrap::{BubbleWrap,BubbleWrapStatus,BubbleWrapRunningStatus,BubbleWrapExitStatus};
|
||||||
|
pub use launcher::GnomeSoftwareLauncher;
|
||||||
|
|
||||||
|
pub const SANDBOX_STATUS_FILE_DIRECTORY: &str = "/run/citadel/realms/gs-sandbox-status";
|
132
libcitadel/src/flatpak/netns.rs
Normal file
132
libcitadel/src/flatpak/netns.rs
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use crate::{util,Result};
|
||||||
|
|
||||||
|
|
||||||
|
const BRIDGE_NAME: &str = "vz-clear";
|
||||||
|
const VETH0: &str = "gs-veth0";
|
||||||
|
const VETH1: &str = "gs-veth1";
|
||||||
|
const IP_ADDRESS: &str = "172.17.0.222/24";
|
||||||
|
const GW_ADDRESS: &str = "172.17.0.1";
|
||||||
|
|
||||||
|
|
||||||
|
pub struct NetNS {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetNS {
|
||||||
|
pub const GS_NETNS_NAME: &'static str = "gnome-software";
|
||||||
|
pub fn new(name: &str) -> Self {
|
||||||
|
NetNS {
|
||||||
|
name: name.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create(&self) -> crate::Result<()> {
|
||||||
|
Ip::new().link_add_veth(VETH0, VETH1).run()?;
|
||||||
|
Ip::new().link_set_netns(VETH0, &self.name).run()?;
|
||||||
|
Ip::new().link_set_master(VETH1, BRIDGE_NAME).run()?;
|
||||||
|
Ip::new().link_set_dev_up(VETH1).run()?;
|
||||||
|
|
||||||
|
Ip::new().ip_netns_exec_ip(&self.name).addr_add(IP_ADDRESS, VETH0).run()?;
|
||||||
|
Ip::new().ip_netns_exec_ip(&self.name).link_set_dev_up(VETH0).run()?;
|
||||||
|
Ip::new().ip_netns_exec_ip(&self.name).route_add_default(GW_ADDRESS).run()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn ensure_exists(&self) -> Result<()> {
|
||||||
|
if Path::new(&format!("/run/netns/{}", self.name)).exists() {
|
||||||
|
verbose!("Network namespace ({}) exists", self.name);
|
||||||
|
return Ok(())
|
||||||
|
}
|
||||||
|
verbose!("Setting up network namespace ({})", self.name);
|
||||||
|
|
||||||
|
Ip::new().netns_add(&self.name).run()
|
||||||
|
.map_err(context!("Failed to add network namespace '{}'", self.name))?;
|
||||||
|
|
||||||
|
if let Err(err) = self.create() {
|
||||||
|
Ip::new().netns_delete(&self.name).run()?;
|
||||||
|
Err(err)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn nsenter(&self) -> Result<()> {
|
||||||
|
util::nsenter_netns(&self.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const IP_PATH: &str = "/usr/sbin/ip";
|
||||||
|
struct Ip {
|
||||||
|
command: Command,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ip {
|
||||||
|
|
||||||
|
fn new() -> Self {
|
||||||
|
let mut command = Command::new(IP_PATH);
|
||||||
|
command.env_clear();
|
||||||
|
Ip { command }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_args<S: AsRef<OsStr>>(&mut self, args: &[S]) -> &mut Self {
|
||||||
|
for arg in args {
|
||||||
|
self.command.arg(arg);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn netns_add(&mut self, name: &str) -> &mut Self {
|
||||||
|
self.add_args(&["netns", "add", name])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn netns_delete(&mut self, name: &str) -> &mut Self {
|
||||||
|
self.add_args(&["netns", "delete", name])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn link_add_veth(&mut self, name: &str, peer_name: &str) -> &mut Self {
|
||||||
|
self.add_args(&["link", "add", name, "type", "veth", "peer", "name", peer_name])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn link_set_netns(&mut self, iface: &str, netns_name: &str) -> &mut Self {
|
||||||
|
self.add_args(&["link", "set", iface, "netns", netns_name])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn link_set_master(&mut self, iface: &str, bridge_name: &str) -> &mut Self {
|
||||||
|
self.add_args(&["link", "set", iface, "master", bridge_name])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn link_set_dev_up(&mut self, iface: &str) -> &mut Self {
|
||||||
|
self.add_args(&["link", "set", "dev", iface, "up"])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ip_netns_exec_ip(&mut self, netns_name: &str) -> &mut Self {
|
||||||
|
self.add_args(&["netns", "exec", netns_name, IP_PATH])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn addr_add(&mut self, ip_address: &str, dev: &str) -> &mut Self {
|
||||||
|
self.add_args(&["addr", "add", ip_address, "dev", dev])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn route_add_default(&mut self, gateway: &str) -> &mut Self {
|
||||||
|
self.add_args(&["route", "add", "default", "via", gateway])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(&mut self) -> crate::Result<()> {
|
||||||
|
verbose!("{:?}", self.command);
|
||||||
|
match self.command.status() {
|
||||||
|
Ok(status) => {
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
bail!("IP command ({:?}) did not succeeed.", self.command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
bail!("error running ip command ({:?}): {}", self.command, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
58
libcitadel/src/flatpak/setup.rs
Normal file
58
libcitadel/src/flatpak/setup.rs
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use crate::{Realm, Result, util};
|
||||||
|
|
||||||
|
const GNOME_SOFTWARE_DESKTOP_TEMPLATE: &str = "\
|
||||||
|
[Desktop Entry]
|
||||||
|
Name=Software
|
||||||
|
Comment=Add, remove or update software on this computer
|
||||||
|
Icon=org.gnome.Software
|
||||||
|
Exec=/usr/libexec/launch-gnome-software --realm $REALM_NAME
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
|
Categories=GNOME;GTK;System;PackageManager;
|
||||||
|
Keywords=Updates;Upgrade;Sources;Repositories;Preferences;Install;Uninstall;Program;Software;App;Store;
|
||||||
|
StartupNotify=true
|
||||||
|
";
|
||||||
|
|
||||||
|
const APPLICATION_DIRECTORY: &str = "/home/citadel/.local/share/applications";
|
||||||
|
|
||||||
|
pub struct FlatpakSetup<'a> {
|
||||||
|
realm: &'a Realm,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl <'a> FlatpakSetup<'a> {
|
||||||
|
|
||||||
|
pub fn new(realm: &'a Realm) -> Self {
|
||||||
|
Self { realm }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn setup(&self) -> Result<()> {
|
||||||
|
self.write_desktop_file()?;
|
||||||
|
self.ensure_flatpak_directory()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn write_desktop_file(&self) -> Result<()> {
|
||||||
|
let appdir = Path::new(APPLICATION_DIRECTORY);
|
||||||
|
if !appdir.exists() {
|
||||||
|
util::create_dir(appdir)?;
|
||||||
|
if let Some(parent) = appdir.parent().and_then(|p| p.parent()) {
|
||||||
|
util::chown_tree(parent, (1000,1000), true)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let path = appdir.join(format!("realm-{}.org.gnome.Software.desktop", self.realm.name()));
|
||||||
|
util::write_file(path, GNOME_SOFTWARE_DESKTOP_TEMPLATE.replace("$REALM_NAME", self.realm.name()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_flatpak_directory(&self) -> Result<()> {
|
||||||
|
let path = self.realm.base_path_file("flatpak");
|
||||||
|
if !path.exists() {
|
||||||
|
util::create_dir(&path)?;
|
||||||
|
util::chown_user(&path)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
144
libcitadel/src/flatpak/status.rs
Normal file
144
libcitadel/src/flatpak/status.rs
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
use crate::flatpak::bubblewrap::BubbleWrapStatus;
|
||||||
|
use crate::{Realm, Result, util};
|
||||||
|
|
||||||
|
|
||||||
|
/// Utility function to read modified time from a path.
|
||||||
|
fn modified_time(path: &Path) -> Result<SystemTime> {
|
||||||
|
path.metadata().and_then(|meta| meta.modified())
|
||||||
|
.map_err(context!("failed to read modified time from '{}'", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Utility function to detect if current modified time of a path
|
||||||
|
/// matches an earlier recorded modified time.
|
||||||
|
fn modified_changed(path: &Path, old_modified: SystemTime) -> bool {
|
||||||
|
if !path.exists() {
|
||||||
|
// Path existed at some earlier point, so something changed
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
match modified_time(path) {
|
||||||
|
Ok(modified) => old_modified != modified,
|
||||||
|
Err(err) => {
|
||||||
|
// Print a warning but assume change
|
||||||
|
warn!("{}", err);
|
||||||
|
true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records the content of single entry in a sandbox status directory.
|
||||||
|
///
|
||||||
|
/// The path to the status file as well as the last modified time are
|
||||||
|
/// recorded so that changes in status of a sandbox can be detected.
|
||||||
|
struct StatusEntry {
|
||||||
|
status: BubbleWrapStatus,
|
||||||
|
path: PathBuf,
|
||||||
|
modified: SystemTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl StatusEntry {
|
||||||
|
|
||||||
|
fn load_timestamp_and_status(path: &Path) -> Result<Option<(SystemTime, BubbleWrapStatus)>> {
|
||||||
|
if path.exists() {
|
||||||
|
let modified = modified_time(path)?;
|
||||||
|
if let Some(status) = BubbleWrapStatus::parse_file(path)? {
|
||||||
|
return Ok(Some((modified, status)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load(base_dir: &Path, name: &str) -> Result<Option<Self>> {
|
||||||
|
let path = base_dir.join(name);
|
||||||
|
let result = StatusEntry::load_timestamp_and_status(&path)?
|
||||||
|
.map(|(modified, status)| StatusEntry { status, path, modified });
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_modified(&self) -> bool {
|
||||||
|
modified_changed(&self.path, self.modified)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Holds information about entries in a sandbox status directory.
|
||||||
|
///
|
||||||
|
/// Bubblewrap accepts a command line argument that asks for status
|
||||||
|
/// information to be written as a json structure to a file descriptor.
|
||||||
|
///
|
||||||
|
pub struct SandboxStatus {
|
||||||
|
base_dir: PathBuf,
|
||||||
|
base_modified: SystemTime,
|
||||||
|
entries: HashMap<String, StatusEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SandboxStatus {
|
||||||
|
|
||||||
|
pub fn need_reload(&self) -> bool {
|
||||||
|
if modified_changed(&self.base_dir, self.base_modified) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.entries.values().any(|entry| entry.is_modified())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_dir_entry(&mut self, dir_entry: PathBuf) -> Result<()> {
|
||||||
|
fn realm_name_for_path(path: &Path) -> Option<&str> {
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.filter(|name| Realm::is_valid_name(name))
|
||||||
|
|
||||||
|
}
|
||||||
|
if dir_entry.is_file() {
|
||||||
|
if let Some(name) = realm_name_for_path(&dir_entry) {
|
||||||
|
if let Some(entry) = StatusEntry::load(&self.base_dir, name)? {
|
||||||
|
self.entries.insert(name.to_string(), entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reload(&mut self) -> Result<()> {
|
||||||
|
self.entries.clear();
|
||||||
|
self.base_modified = modified_time(&self.base_dir)?;
|
||||||
|
let base_dir = self.base_dir.clone();
|
||||||
|
util::read_directory(&base_dir, |entry| {
|
||||||
|
self.process_dir_entry(entry.path())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(base_dir: &Path) -> Result<Self> {
|
||||||
|
let base_dir = base_dir.to_owned();
|
||||||
|
let base_modified = modified_time(&base_dir)?;
|
||||||
|
Ok(SandboxStatus {
|
||||||
|
base_dir,
|
||||||
|
base_modified,
|
||||||
|
entries: HashMap::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(directory: impl AsRef<Path>) -> Result<SandboxStatus> {
|
||||||
|
let base_dir = directory.as_ref();
|
||||||
|
if !base_dir.exists() {
|
||||||
|
util::create_dir(base_dir)?;
|
||||||
|
}
|
||||||
|
let mut status = SandboxStatus::new(base_dir)?;
|
||||||
|
status.reload()?;
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn realm_status(&self, realm: &Realm) -> Option<BubbleWrapStatus> {
|
||||||
|
self.entries.get(realm.name()).map(|entry| entry.status.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_realm_status_file(&self, realm: &Realm) -> Result<File> {
|
||||||
|
let path = self.base_dir.join(realm.name());
|
||||||
|
File::create(&path)
|
||||||
|
.map_err(context!("failed to open sandbox status file {}", path.display()))
|
||||||
|
}
|
||||||
|
}
|
@ -21,6 +21,8 @@ mod realm;
|
|||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
mod system;
|
mod system;
|
||||||
|
|
||||||
|
pub mod flatpak;
|
||||||
|
|
||||||
pub use crate::config::OsRelease;
|
pub use crate::config::OsRelease;
|
||||||
pub use crate::blockdev::BlockDev;
|
pub use crate::blockdev::BlockDev;
|
||||||
pub use crate::cmdline::CommandLine;
|
pub use crate::cmdline::CommandLine;
|
||||||
|
@ -62,6 +62,11 @@ impl Logger {
|
|||||||
logger.level = level;
|
logger.level = level;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_log_level(level: LogLevel) -> bool {
|
||||||
|
let logger = LOGGER.lock().unwrap();
|
||||||
|
logger.level >= level
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_log_output(output: Box<dyn LogOutput>) {
|
pub fn set_log_output(output: Box<dyn LogOutput>) {
|
||||||
let mut logger = LOGGER.lock().unwrap();
|
let mut logger = LOGGER.lock().unwrap();
|
||||||
logger.output = output;
|
logger.output = output;
|
||||||
|
@ -77,6 +77,9 @@ pub struct RealmConfig {
|
|||||||
#[serde(rename="use-fuse")]
|
#[serde(rename="use-fuse")]
|
||||||
pub use_fuse: Option<bool>,
|
pub use_fuse: Option<bool>,
|
||||||
|
|
||||||
|
#[serde(rename="use-flatpak")]
|
||||||
|
pub use_flatpak: Option<bool>,
|
||||||
|
|
||||||
#[serde(rename="use-gpu")]
|
#[serde(rename="use-gpu")]
|
||||||
pub use_gpu: Option<bool>,
|
pub use_gpu: Option<bool>,
|
||||||
|
|
||||||
@ -201,6 +204,7 @@ impl RealmConfig {
|
|||||||
wayland_socket: Some("wayland-0".to_string()),
|
wayland_socket: Some("wayland-0".to_string()),
|
||||||
use_kvm: Some(false),
|
use_kvm: Some(false),
|
||||||
use_fuse: Some(false),
|
use_fuse: Some(false),
|
||||||
|
use_flatpak: Some(false),
|
||||||
use_gpu: Some(false),
|
use_gpu: Some(false),
|
||||||
use_gpu_card0: Some(false),
|
use_gpu_card0: Some(false),
|
||||||
use_network: Some(true),
|
use_network: Some(true),
|
||||||
@ -233,6 +237,7 @@ impl RealmConfig {
|
|||||||
wayland_socket: None,
|
wayland_socket: None,
|
||||||
use_kvm: None,
|
use_kvm: None,
|
||||||
use_fuse: None,
|
use_fuse: None,
|
||||||
|
use_flatpak: None,
|
||||||
use_gpu: None,
|
use_gpu: None,
|
||||||
use_gpu_card0: None,
|
use_gpu_card0: None,
|
||||||
use_network: None,
|
use_network: None,
|
||||||
@ -267,6 +272,14 @@ impl RealmConfig {
|
|||||||
self.bool_value(|c| c.use_fuse)
|
self.bool_value(|c| c.use_fuse)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// If `true` flatpak directory will be mounted into realm
|
||||||
|
/// and a desktop file will be created to launch gnome-software
|
||||||
|
///
|
||||||
|
pub fn flatpak(&self) -> bool {
|
||||||
|
self.bool_value(|c| c.use_flatpak)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// If `true` render node device /dev/dri/renderD128 will be added to realm.
|
/// If `true` render node device /dev/dri/renderD128 will be added to realm.
|
||||||
///
|
///
|
||||||
/// This enables hardware graphics acceleration in realm.
|
/// This enables hardware graphics acceleration in realm.
|
||||||
|
@ -60,7 +60,7 @@ impl <'a> RealmLauncher <'a> {
|
|||||||
if config.kvm() {
|
if config.kvm() {
|
||||||
self.add_device("/dev/kvm");
|
self.add_device("/dev/kvm");
|
||||||
}
|
}
|
||||||
if config.fuse() {
|
if config.fuse() || config.flatpak() {
|
||||||
self.add_device("/dev/fuse");
|
self.add_device("/dev/fuse");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,6 +153,10 @@ impl <'a> RealmLauncher <'a> {
|
|||||||
writeln!(s, "BindReadOnly=/run/user/1000/{}:/run/user/host/wayland-0", config.wayland_socket())?;
|
writeln!(s, "BindReadOnly=/run/user/1000/{}:/run/user/host/wayland-0", config.wayland_socket())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.flatpak() {
|
||||||
|
writeln!(s, "BindReadOnly={}:/var/lib/flatpak", self.realm.base_path_file("flatpak").display())?;
|
||||||
|
}
|
||||||
|
|
||||||
for bind in config.extra_bindmounts() {
|
for bind in config.extra_bindmounts() {
|
||||||
if Self::is_valid_bind_item(bind) {
|
if Self::is_valid_bind_item(bind) {
|
||||||
writeln!(s, "Bind={}", bind)?;
|
writeln!(s, "Bind={}", bind)?;
|
||||||
|
@ -4,6 +4,8 @@ use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
|
|||||||
use posix_acl::{ACL_EXECUTE, ACL_READ, PosixACL, Qualifier};
|
use posix_acl::{ACL_EXECUTE, ACL_READ, PosixACL, Qualifier};
|
||||||
|
|
||||||
use crate::{Mountpoint, Result, Realms, RealmFS, Realm, util};
|
use crate::{Mountpoint, Result, Realms, RealmFS, Realm, util};
|
||||||
|
use crate::flatpak::GnomeSoftwareLauncher;
|
||||||
|
use crate::flatpak::setup::FlatpakSetup;
|
||||||
use crate::realm::pidmapper::{PidLookupResult, PidMapper};
|
use crate::realm::pidmapper::{PidLookupResult, PidMapper};
|
||||||
use crate::realmfs::realmfs_set::RealmFSSet;
|
use crate::realmfs::realmfs_set::RealmFSSet;
|
||||||
|
|
||||||
@ -21,6 +23,7 @@ struct Inner {
|
|||||||
events: RealmEventListener,
|
events: RealmEventListener,
|
||||||
realms: Realms,
|
realms: Realms,
|
||||||
realmfs_set: RealmFSSet,
|
realmfs_set: RealmFSSet,
|
||||||
|
pid_mapper: PidMapper,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Inner {
|
impl Inner {
|
||||||
@ -28,7 +31,8 @@ impl Inner {
|
|||||||
let events = RealmEventListener::new();
|
let events = RealmEventListener::new();
|
||||||
let realms = Realms::load()?;
|
let realms = Realms::load()?;
|
||||||
let realmfs_set = RealmFSSet::load()?;
|
let realmfs_set = RealmFSSet::load()?;
|
||||||
Ok(Inner { events, realms, realmfs_set })
|
let pid_mapper = PidMapper::new()?;
|
||||||
|
Ok(Inner { events, realms, realmfs_set, pid_mapper })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,6 +234,10 @@ impl RealmManager {
|
|||||||
self.ensure_run_media_directory()?;
|
self.ensure_run_media_directory()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if realm.config().flatpak() {
|
||||||
|
FlatpakSetup::new(realm).setup()?;
|
||||||
|
}
|
||||||
|
|
||||||
self.systemd.start_realm(realm, &rootfs)?;
|
self.systemd.start_realm(realm, &rootfs)?;
|
||||||
|
|
||||||
self.create_realm_namefile(realm)?;
|
self.create_realm_namefile(realm)?;
|
||||||
@ -268,6 +276,15 @@ impl RealmManager {
|
|||||||
self.run_in_realm(realm, &["/usr/bin/ln", "-s", "/run/user/host/wayland-0", "/run/user/1000/wayland-0"], false)
|
self.run_in_realm(realm, &["/usr/bin/ln", "-s", "/run/user/host/wayland-0", "/run/user/1000/wayland-0"], false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stop_gnome_software_sandbox(&self, realm: &Realm) -> Result<()> {
|
||||||
|
let launcher = GnomeSoftwareLauncher::new(realm.clone())?;
|
||||||
|
if launcher.is_running() {
|
||||||
|
info!("Stopping GNOME Software sandbox for {}", realm.name());
|
||||||
|
launcher.quit()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn stop_realm(&self, realm: &Realm) -> Result<()> {
|
pub fn stop_realm(&self, realm: &Realm) -> Result<()> {
|
||||||
if !realm.is_active() {
|
if !realm.is_active() {
|
||||||
info!("ignoring stop request on realm '{}' which is not running", realm.name());
|
info!("ignoring stop request on realm '{}' which is not running", realm.name());
|
||||||
@ -276,6 +293,12 @@ impl RealmManager {
|
|||||||
|
|
||||||
info!("Stopping realm {}", realm.name());
|
info!("Stopping realm {}", realm.name());
|
||||||
|
|
||||||
|
if realm.config().flatpak() {
|
||||||
|
if let Err(err) = self.stop_gnome_software_sandbox(realm) {
|
||||||
|
warn!("Error stopping GNOME Software sandbox: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
realm.set_active(false);
|
realm.set_active(false);
|
||||||
self.systemd.stop_realm(realm)?;
|
self.systemd.stop_realm(realm)?;
|
||||||
realm.cleanup_rootfs();
|
realm.cleanup_rootfs();
|
||||||
@ -335,8 +358,8 @@ impl RealmManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn realm_by_pid(&self, pid: u32) -> PidLookupResult {
|
pub fn realm_by_pid(&self, pid: u32) -> PidLookupResult {
|
||||||
let mapper = PidMapper::new(self.active_realms(false));
|
let realms = self.realm_list();
|
||||||
mapper.lookup_pid(pid as libc::pid_t)
|
self.inner_mut().pid_mapper.lookup_pid(pid as libc::pid_t, realms)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rescan_realms(&self) -> Result<(Vec<Realm>,Vec<Realm>)> {
|
pub fn rescan_realms(&self) -> Result<(Vec<Realm>,Vec<Realm>)> {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use procfs::process::Process;
|
use procfs::process::Process;
|
||||||
use crate::Realm;
|
use crate::{Result, Realm};
|
||||||
|
use crate::flatpak::{SANDBOX_STATUS_FILE_DIRECTORY, SandboxStatus};
|
||||||
|
|
||||||
pub enum PidLookupResult {
|
pub enum PidLookupResult {
|
||||||
Unknown,
|
Unknown,
|
||||||
@ -9,14 +10,15 @@ pub enum PidLookupResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct PidMapper {
|
pub struct PidMapper {
|
||||||
active_realms: Vec<Realm>,
|
sandbox_status: SandboxStatus,
|
||||||
my_pid_ns_id: Option<u64>,
|
my_pid_ns_id: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PidMapper {
|
impl PidMapper {
|
||||||
pub fn new(active_realms: Vec<Realm>) -> Self {
|
pub fn new() -> Result<Self> {
|
||||||
|
let sandbox_status = SandboxStatus::load(SANDBOX_STATUS_FILE_DIRECTORY)?;
|
||||||
let my_pid_ns_id = Self::self_pid_namespace_id();
|
let my_pid_ns_id = Self::self_pid_namespace_id();
|
||||||
PidMapper { active_realms, my_pid_ns_id }
|
Ok(PidMapper { sandbox_status, my_pid_ns_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_process(pid: libc::pid_t) -> Option<Process> {
|
fn read_process(pid: libc::pid_t) -> Option<Process> {
|
||||||
@ -72,7 +74,30 @@ impl PidMapper {
|
|||||||
Self::read_process(ppid)
|
Self::read_process(ppid)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn lookup_pid(&self, pid: libc::pid_t) -> PidLookupResult {
|
fn refresh_sandbox_status(&mut self) -> Result<()> {
|
||||||
|
if self.sandbox_status.need_reload() {
|
||||||
|
self.sandbox_status.reload()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_sandbox_realms(&mut self, pid_ns: u64, realms: &[Realm]) -> Option<Realm> {
|
||||||
|
if let Err(err) = self.refresh_sandbox_status() {
|
||||||
|
warn!("error reloading sandbox status directory: {}", err);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
for r in realms {
|
||||||
|
if let Some(status) = self.sandbox_status.realm_status(r) {
|
||||||
|
if status.pid_namespace() == pid_ns {
|
||||||
|
return Some(r.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lookup_pid(&mut self, pid: libc::pid_t, realms: Vec<Realm>) -> PidLookupResult {
|
||||||
const MAX_PARENT_SEARCH: i32 = 8;
|
const MAX_PARENT_SEARCH: i32 = 8;
|
||||||
let mut n = 0;
|
let mut n = 0;
|
||||||
|
|
||||||
@ -92,13 +117,17 @@ impl PidMapper {
|
|||||||
return PidLookupResult::Citadel;
|
return PidLookupResult::Citadel;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(realm) = self.active_realms.iter()
|
if let Some(realm) = realms.iter()
|
||||||
.find(|r| r.has_pid_ns(pid_ns_id))
|
.find(|r| r.is_active() && r.has_pid_ns(pid_ns_id))
|
||||||
.cloned()
|
.cloned()
|
||||||
{
|
{
|
||||||
return PidLookupResult::Realm(realm)
|
return PidLookupResult::Realm(realm)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(r) = self.search_sandbox_realms(pid_ns_id, &realms) {
|
||||||
|
return PidLookupResult::Realm(r)
|
||||||
|
}
|
||||||
|
|
||||||
proc = match Self::parent_process(proc) {
|
proc = match Self::parent_process(proc) {
|
||||||
Some(proc) => proc,
|
Some(proc) => proc,
|
||||||
None => return PidLookupResult::Unknown,
|
None => return PidLookupResult::Unknown,
|
||||||
@ -108,5 +137,4 @@ impl PidMapper {
|
|||||||
}
|
}
|
||||||
PidLookupResult::Unknown
|
PidLookupResult::Unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -279,6 +279,9 @@ impl Realm {
|
|||||||
symlink::write(&rootfs, self.rootfs_symlink(), false)?;
|
symlink::write(&rootfs, self.rootfs_symlink(), false)?;
|
||||||
symlink::write(mountpoint.path(), self.realmfs_mountpoint_symlink(), false)?;
|
symlink::write(mountpoint.path(), self.realmfs_mountpoint_symlink(), false)?;
|
||||||
symlink::write(self.base_path().join("home"), self.run_path().join("home"), false)?;
|
symlink::write(self.base_path().join("home"), self.run_path().join("home"), false)?;
|
||||||
|
if self.config().flatpak() {
|
||||||
|
symlink::write(self.base_path().join("flatpak"), self.run_path().join("flatpak"), false)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(rootfs)
|
Ok(rootfs)
|
||||||
}
|
}
|
||||||
@ -300,6 +303,9 @@ impl Realm {
|
|||||||
Self::remove_symlink(self.realmfs_mountpoint_symlink());
|
Self::remove_symlink(self.realmfs_mountpoint_symlink());
|
||||||
Self::remove_symlink(self.rootfs_symlink());
|
Self::remove_symlink(self.rootfs_symlink());
|
||||||
Self::remove_symlink(self.run_path().join("home"));
|
Self::remove_symlink(self.run_path().join("home"));
|
||||||
|
if self.config().flatpak() {
|
||||||
|
Self::remove_symlink(self.run_path().join("flatpak"));
|
||||||
|
}
|
||||||
|
|
||||||
if let Err(e) = fs::remove_dir(self.run_path()) {
|
if let Err(e) = fs::remove_dir(self.run_path()) {
|
||||||
warn!("failed to remove run directory {}: {}", self.run_path().display(), e);
|
warn!("failed to remove run directory {}: {}", self.run_path().display(), e);
|
||||||
|
@ -31,6 +31,28 @@ impl Systemd {
|
|||||||
if realm.config().ephemeral_home() {
|
if realm.config().ephemeral_home() {
|
||||||
self.setup_ephemeral_home(realm)?;
|
self.setup_ephemeral_home(realm)?;
|
||||||
}
|
}
|
||||||
|
if realm.config().flatpak() {
|
||||||
|
self.setup_flatpak_workaround(realm)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// What even is this??
|
||||||
|
//
|
||||||
|
// Good question.
|
||||||
|
//
|
||||||
|
// https://bugzilla.redhat.com/show_bug.cgi?id=2210335#c10
|
||||||
|
//
|
||||||
|
fn setup_flatpak_workaround(&self, realm: &Realm) -> Result<()> {
|
||||||
|
let commands = &[
|
||||||
|
vec!["/usr/bin/mount", "-m", "-t","proc", "proc", "/run/flatpak-workaround/proc"],
|
||||||
|
vec!["/usr/bin/chmod", "700", "/run/flatpak-workaround"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for cmd in commands {
|
||||||
|
Self::machinectl_shell(realm, cmd, "root", false, true)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ use std::env;
|
|||||||
use std::fs::{self, File, DirEntry};
|
use std::fs::{self, File, DirEntry};
|
||||||
use std::ffi::CString;
|
use std::ffi::CString;
|
||||||
use std::io::{self, Seek, Read, BufReader, SeekFrom};
|
use std::io::{self, Seek, Read, BufReader, SeekFrom};
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
@ -217,7 +218,8 @@ where
|
|||||||
///
|
///
|
||||||
pub fn remove_file(path: impl AsRef<Path>) -> Result<()> {
|
pub fn remove_file(path: impl AsRef<Path>) -> Result<()> {
|
||||||
let path = path.as_ref();
|
let path = path.as_ref();
|
||||||
if path.exists() {
|
let is_symlink = fs::symlink_metadata(path).is_ok();
|
||||||
|
if is_symlink || path.exists() {
|
||||||
fs::remove_file(path)
|
fs::remove_file(path)
|
||||||
.map_err(context!("failed to remove file {:?}", path))?;
|
.map_err(context!("failed to remove file {:?}", path))?;
|
||||||
}
|
}
|
||||||
@ -368,9 +370,38 @@ pub fn touch_mtime(path: &Path) -> Result<()> {
|
|||||||
|
|
||||||
utimes(path, meta.atime(),mtime)?;
|
utimes(path, meta.atime(),mtime)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn nsenter_netns(netns: &str) -> Result<()> {
|
||||||
|
let mut path = PathBuf::from("/run/netns");
|
||||||
|
path.push(netns);
|
||||||
|
if !path.exists() {
|
||||||
|
bail!("Network namespace '{}' does not exist", netns);
|
||||||
|
}
|
||||||
|
let f = File::open(&path)
|
||||||
|
.map_err(context!("error opening netns file {}", path.display()))?;
|
||||||
|
|
||||||
|
let fd = f.as_raw_fd();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
if libc::setns(fd, libc::CLONE_NEWNET) == -1 {
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
bail!("failed to setns() into network namespace '{}': {}", netns, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn drop_privileges(uid: u32, gid: u32) -> Result<()> {
|
||||||
|
unsafe {
|
||||||
|
if libc::setgid(gid) == -1 {
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
bail!("failed to call setgid({}): {}", gid, err);
|
||||||
|
|
||||||
|
} else if libc::setuid(uid) == -1 {
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
bail!("failed to call setuid({}): {}", uid, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
@ -4,6 +4,6 @@ StartLimitIntervalSec=0
|
|||||||
|
|
||||||
[Path]
|
[Path]
|
||||||
PathChanged=/run/citadel/realms/current/current.realm/rootfs/usr/share/applications
|
PathChanged=/run/citadel/realms/current/current.realm/rootfs/usr/share/applications
|
||||||
PathChanged=/run/citadel/realms/current/current.realm/rootfs/var/lib/flatpak/exports/share/applications
|
PathChanged=/run/citadel/realms/current/current.realm/flatpak/exports/share/applications
|
||||||
PathChanged=/run/citadel/realms/current/current.realm/home/.local/share/applications
|
PathChanged=/run/citadel/realms/current/current.realm/home/.local/share/applications
|
||||||
PathChanged=/run/citadel/realms/current/current.realm/home/.local/share/flatpak/exports/share/applications
|
PathChanged=/run/citadel/realms/current/current.realm/home/.local/share/flatpak/exports/share/applications
|
||||||
|
Loading…
Reference in New Issue
Block a user