This creates a separate script dangerzone-container which is a wrapper for running the container. This lets us run dangerzone as unprivileged, but dangerzone-container as privileged, to avoid adding the user to the dangerzone group.

This commit is contained in:
Micah Lee 2020-03-13 16:49:53 -07:00
parent 0b1cd9e9ef
commit cf367adcfa
No known key found for this signature in database
GPG key ID: 403C2657CD994F73
12 changed files with 424 additions and 215 deletions

View file

@ -1,146 +1,13 @@
from PyQt5 import QtCore, QtWidgets
import os
import sys
import signal
import platform
import click
import time
import uuid
import subprocess
from .global_common import GlobalCommon
from .main_window import MainWindow
from .docker_installer import (
is_docker_installed,
is_docker_ready,
launch_docker_windows,
DockerInstaller,
)
from .container import container_main
dangerzone_version = "0.1"
class Application(QtWidgets.QApplication):
document_selected = QtCore.pyqtSignal(str)
application_activated = QtCore.pyqtSignal()
def __init__(self):
QtWidgets.QApplication.__init__(self, sys.argv)
def event(self, event):
# In macOS, handle the file open event
if event.type() == QtCore.QEvent.FileOpen:
self.document_selected.emit(event.file())
return True
elif event.type() == QtCore.QEvent.ApplicationActivate:
self.application_activated.emit()
return True
return QtWidgets.QApplication.event(self, event)
@click.command()
@click.option("--custom-container") # Use this container instead of flmcode/dangerzone
@click.argument("filename", required=False)
def main(custom_container, filename):
click.echo(f"dangerzone {dangerzone_version}")
# Create the Qt app
app = Application()
app.setQuitOnLastWindowClosed(False)
# GlobalCommon object
global_common = GlobalCommon(app)
if custom_container:
# Do we have this container?
output = subprocess.check_output(
[global_common.container_runtime, "image", "ls", custom_container],
startupinfo=global_common.get_subprocess_startupinfo(),
)
if custom_container.encode() not in output:
click.echo(f"Container '{container}' not found")
return
global_common.custom_container = custom_container
# Allow Ctrl-C to smoothly quit the program instead of throwing an exception
signal.signal(signal.SIGINT, signal.SIG_DFL)
# If we're using Linux and docker, see if we need to add the user to the docker group
if (
platform.system() == "Linux"
and global_common.container_runtime == "/usr/bin/docker"
):
if not global_common.ensure_user_is_in_docker_group():
click.echo("Failed to add user to docker group")
return
if not global_common.ensure_docker_service_is_started():
click.echo("Failed to start docker service")
return
# See if we need to install Docker...
if (platform.system() == "Darwin" or platform.system() == "Windows") and (
not is_docker_installed(global_common) or not is_docker_ready(global_common)
):
click.echo("Docker is either not installed or not running")
docker_installer = DockerInstaller(global_common)
docker_installer.start()
return
closed_windows = {}
windows = {}
def delete_window(window_id):
closed_windows[window_id] = windows[window_id]
del windows[window_id]
# Open a document in a window
def select_document(filename=None):
if (
len(windows) == 1
and windows[list(windows.keys())[0]].common.document_filename == None
):
window = windows[list(windows.keys())[0]]
else:
window_id = uuid.uuid4().hex
window = MainWindow(global_common, window_id)
window.delete_window.connect(delete_window)
windows[window_id] = window
if filename:
# Validate filename
filename = os.path.abspath(os.path.expanduser(filename))
try:
open(filename, "rb")
except FileNotFoundError:
click.echo("File not found")
return False
except PermissionError:
click.echo("Permission denied")
return False
window.common.document_filename = filename
window.doc_selection_widget.document_selected.emit()
return True
# Open a new window if not filename is passed
if filename is None:
select_document()
else:
# If filename is passed as an argument, open it
if not select_document(filename):
return True
# Open a new window, if all windows are closed
def application_activated():
if len(windows) == 0:
select_document()
# If we get a file open event, open it
app.document_selected.connect(select_document)
# If the application is activated and all windows are closed, open a new one
app.application_activated.connect(application_activated)
sys.exit(app.exec_())
# This is a hack for Windows and Mac to be able to run dangerzone-container, even though
# PyInstaller builds a single binary
if os.path.basename(sys.argv[0]) == "dangerzone-container":
main = container_main
else:
# If the binary isn't "dangerzone-contatiner", then launch the GUI
from .gui import gui_main as main

115
dangerzone/container.py Normal file
View file

@ -0,0 +1,115 @@
import click
import platform
import subprocess
import sys
import pipes
import getpass
# What is the container runtime for this platform?
if platform.system() == "Darwin":
container_runtime = "/usr/local/bin/docker"
elif platform.system() == "Windows":
container_runtime = "C:\\Program Files\\Docker\\Docker\\resources\\bin\\docker.exe"
else:
container_runtime = "/usr/bin/docker"
# Define startupinfo for subprocesses
if platform.system() == "Windows":
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
else:
startupinfo = None
def exec_container(args):
args = [container_runtime] + args
args_str = " ".join(pipes.quote(s) for s in args)
sys.stdout.write(f"Executing: {args_str}\n\n")
sys.stdout.flush()
with subprocess.Popen(
args,
stdin=None,
stdout=sys.stdout,
stderr=sys.stderr,
bufsize=1,
universal_newlines=True,
startupinfo=startupinfo,
) as p:
p.communicate()
return p.returncode
@click.group()
def container_main():
"""
Dangerzone container commands with elevated privileges.
Humans don't need to run this command by themselves.
"""
pass
@container_main.command()
@click.option("--container-name", default="flmcode/dangerzone")
def image_ls(container_name):
"""docker image ls [container_name]"""
sys.exit(exec_container(["image", "ls", container_name]))
@container_main.command()
def pull():
"""docker pull flmcode/dangerzone"""
sys.exit(exec_container(["pull", "flmcode/dangerzone"]))
@container_main.command()
@click.option("--document-filename", required=True)
@click.option("--pixel-dir", required=True)
@click.option("--container-name", default="flmcode/dangerzone")
def document_to_pixels(document_filename, pixel_dir, container_name):
"""docker run --network none -v [document_filename]:/tmp/input_file -v [pixel_dir]:/dangerzone [container_name] document-to-pixels"""
sys.exit(
exec_container(
[
"run",
"--network",
"none",
"-v",
f"{document_filename}:/tmp/input_file",
"-v",
f"{pixel_dir}:/dangerzone",
container_name,
"document-to-pixels",
]
)
)
@container_main.command()
@click.option("--pixel-dir", required=True)
@click.option("--safe-dir", required=True)
@click.option("--container-name", default="flmcode/dangerzone")
@click.option("--ocr", required=True)
@click.option("--ocr-lang", required=True)
def pixels_to_pdf(pixel_dir, safe_dir, container_name, ocr, ocr_lang):
"""docker run --network none -v [pixel_dir]:/dangerzone -v [safe_dir]:/safezone [container_name] -e OCR=[ocr] -e OCR_LANGUAGE=[ocr_lang] pixels-to-pdf"""
sys.exit(
exec_container(
[
"run",
"--network",
"none",
"-v",
f"{pixel_dir}:/dangerzone",
"-v",
f"{safe_dir}:/safezone",
"-e",
f"OCR={ocr}",
"-e",
f"OCR_LANGUAGE={ocr_lang}",
container_name,
"pixels-to-pdf",
]
)
)

View file

@ -8,29 +8,31 @@ import time
import platform
from PyQt5 import QtCore, QtGui, QtWidgets
from .container import container_runtime
def is_docker_installed(global_common):
if platform.system() == "Darwin":
# Does the docker binary exist?
if os.path.isdir("/Applications/Docker.app") and os.path.exists(
global_common.container_runtime
container_runtime
):
# Is it executable?
st = os.stat(global_common.container_runtime)
st = os.stat(container_runtime)
return bool(st.st_mode & stat.S_IXOTH)
if platform.system() == "Windows":
return os.path.exists(global_common.container_runtime)
return os.path.exists(container_runtime)
return False
def is_docker_ready(global_common):
# Run `docker ps` without an error
# Run `docker image ls` without an error
try:
print(global_common.get_dangerzone_container_args())
subprocess.run(
[global_common.container_runtime, "ps"],
check=True,
global_common.get_dangerzone_container_args() + ["image-ls"],
startupinfo=global_common.get_subprocess_startupinfo(),
)
return True

View file

@ -57,26 +57,12 @@ class GlobalCommon(object):
# App data folder
self.appdata_path = appdirs.user_config_dir("dangerzone")
# Container runtime
if platform.system() == "Darwin":
self.container_runtime = "/usr/local/bin/docker"
elif platform.system() == "Windows":
self.container_runtime = (
"C:\\Program Files\\Docker\\Docker\\resources\\bin\\docker.exe"
)
else:
# Linux
# If this is fedora-like, use podman
if os.path.exists("/usr/bin/dnf"):
self.container_runtime = "/usr/bin/podman"
# Otherwise, use docker
else:
self.container_runtime = "/usr/bin/docker"
# In case we have a custom container
self.custom_container = None
# dangerzone-container path
self.dz_container_path = self.get_dangerzone_container_path()
# Preload list of PDF viewers on computer
self.pdf_viewers = self._find_pdf_viewers()
@ -278,6 +264,35 @@ class GlobalCommon(object):
resource_path = os.path.join(prefix, filename)
return resource_path
def get_dangerzone_container_path(self):
if getattr(sys, "dangerzone_dev", False):
# Look for resources directory relative to python file
return os.path.join(
os.path.dirname(
os.path.dirname(
os.path.abspath(inspect.getfile(inspect.currentframe()))
)
),
"dev_scripts",
"dangerzone-container",
)
else:
if platform.system() == "Darwin":
return os.path.join(os.path.dirname(sys.executable), "dangerzone-container")
elif platform.system() == "Windows":
return os.path.join(os.path.dirname(sys.executable), "dangerzone-container.exe")
else:
return "/usr/bin/dangerzone-container"
def get_dangerzone_container_args(self):
if platform.system() == "Linux":
if self.settings.get("linux_prefers_typing_password"):
return ["/usr/bin/pkexec", self.dz_container_path]
else:
return [self.dz_container_path]
else:
return [self.dz_container_path]
def get_window_icon(self):
if platform.system() == "Windows":
path = self.get_resource_path("dangerzone.ico")
@ -372,18 +387,40 @@ class GlobalCommon(object):
return pdf_viewers
def ensure_user_is_in_docker_group(self):
def ensure_docker_group_preference(self):
# If the user prefers typing their password
if self.settings.get("linux_prefers_typing_password") == True:
return True
# Get the docker group
try:
groupinfo = grp.getgrnam("docker")
except:
# Ignore if group is not found
return True
# See if the user is in the group
username = getpass.getuser()
if username not in groupinfo.gr_mem:
# User is not in docker group, so prompt about adding the user to the docker group
message = "<b>Dangerzone requires Docker.</b><br><br>Click Ok to add your user to the 'docker' group. You will have to type your login password."
if Alert(self, message).launch():
# User is not in the docker group, ask if they prefer typing their password
message = "<b>Dangerzone requires Docker</b><br><br>In order to use Docker, your user must be in the 'docker' group or you'll need to type your password each time you run dangerzone.<br><br><b>Adding your user to the 'docker' group is more convenient but less secure</b>, and will require just typing your password once. Which do you prefer?"
return_code = Alert(
self,
message,
ok_text="I'll type my password each time",
extra_button_text="Add my user to the 'docker' group",
).launch()
if return_code == QtWidgets.QDialog.Accepted:
# Prefers typing password
self.settings.set("linux_prefers_typing_password", True)
self.settings.save()
return True
elif return_code == 2:
# Prefers being in the docker group
self.settings.set("linux_prefers_typing_password", False)
self.settings.save()
# Add user to the docker group
p = subprocess.run(
[
"/usr/bin/pkexec",
@ -401,14 +438,17 @@ class GlobalCommon(object):
message = "Failed to add your user to the 'docker' group, quitting."
Alert(self, message).launch()
return False
return False
else:
# Cancel
return False
return True
def ensure_docker_service_is_started(self):
if not is_docker_ready(self):
message = "<b>Dangerzone requires Docker.</b><br><br>Docker should be installed, but it looks like it's not running in the background.<br><br>Click Ok to try starting the docker service. You will have to type your login password."
if Alert(self, message).launch():
message = "<b>Dangerzone requires Docker</b><br><br>Docker should be installed, but it looks like it's not running in the background.<br><br>Click Ok to try starting the docker service. You will have to type your login password."
if Alert(self, message).launch() == QtWidgets.QDialog.Accepted:
p = subprocess.run(
[
"/usr/bin/pkexec",
@ -440,7 +480,7 @@ class GlobalCommon(object):
class Alert(QtWidgets.QDialog):
def __init__(self, common, message):
def __init__(self, common, message, ok_text="Ok", extra_button_text=None):
super(Alert, self).__init__()
self.common = common
@ -470,22 +510,38 @@ class Alert(QtWidgets.QDialog):
message_layout = QtWidgets.QHBoxLayout()
message_layout.addWidget(logo)
message_layout.addWidget(label)
message_layout.addSpacing(10)
message_layout.addWidget(label, stretch=1)
ok_button = QtWidgets.QPushButton("Ok")
ok_button.clicked.connect(self.accept)
ok_button = QtWidgets.QPushButton(ok_text)
ok_button.clicked.connect(self.clicked_ok)
if extra_button_text:
extra_button = QtWidgets.QPushButton(extra_button_text)
extra_button.clicked.connect(self.clicked_extra)
cancel_button = QtWidgets.QPushButton("Cancel")
cancel_button.clicked.connect(self.reject)
cancel_button.clicked.connect(self.clicked_cancel)
buttons_layout = QtWidgets.QHBoxLayout()
buttons_layout.addStretch()
buttons_layout.addWidget(ok_button)
if extra_button_text:
buttons_layout.addWidget(extra_button)
buttons_layout.addWidget(cancel_button)
layout = QtWidgets.QVBoxLayout()
layout.addLayout(message_layout)
layout.addSpacing(10)
layout.addLayout(buttons_layout)
self.setLayout(layout)
def clicked_ok(self):
self.done(QtWidgets.QDialog.Accepted)
def clicked_extra(self):
self.done(2)
def clicked_cancel(self):
self.done(QtWidgets.QDialog.Rejected)
def launch(self):
return self.exec_() == QtWidgets.QDialog.Accepted
return self.exec_()

140
dangerzone/gui.py Normal file
View file

@ -0,0 +1,140 @@
from PyQt5 import QtCore, QtWidgets
import os
import sys
import signal
import platform
import click
import time
import uuid
import subprocess
from .global_common import GlobalCommon
from .main_window import MainWindow
from .docker_installer import (
is_docker_installed,
is_docker_ready,
launch_docker_windows,
DockerInstaller,
)
from .container import container_runtime
class Application(QtWidgets.QApplication):
document_selected = QtCore.pyqtSignal(str)
application_activated = QtCore.pyqtSignal()
def __init__(self):
QtWidgets.QApplication.__init__(self, sys.argv)
def event(self, event):
# In macOS, handle the file open event
if event.type() == QtCore.QEvent.FileOpen:
self.document_selected.emit(event.file())
return True
elif event.type() == QtCore.QEvent.ApplicationActivate:
self.application_activated.emit()
return True
return QtWidgets.QApplication.event(self, event)
@click.command()
@click.option("--custom-container") # Use this container instead of flmcode/dangerzone
@click.argument("filename", required=False)
def gui_main(custom_container, filename):
# Create the Qt app
app = Application()
app.setQuitOnLastWindowClosed(False)
# GlobalCommon object
global_common = GlobalCommon(app)
if custom_container:
# Do we have this container?
output = subprocess.check_output(
global_common.get_dangerzone_container_args()
+ ["image-ls", custom_container],
startupinfo=global_common.get_subprocess_startupinfo(),
)
if custom_container.encode() not in output:
click.echo(f"Container '{container}' not found")
return
global_common.custom_container = custom_container
# Allow Ctrl-C to smoothly quit the program instead of throwing an exception
signal.signal(signal.SIGINT, signal.SIG_DFL)
# If we're using Linux and docker, see if we need to add the user to the docker group or if the user prefers typing their password
if platform.system() == "Linux" and container_runtime == "/usr/bin/docker":
if not global_common.ensure_docker_group_preference():
return
if not global_common.ensure_docker_service_is_started():
click.echo("Failed to start docker service")
return
# See if we need to install Docker...
if (platform.system() == "Darwin" or platform.system() == "Windows") and (
not is_docker_installed(global_common) or not is_docker_ready(global_common)
):
click.echo("Docker is either not installed or not running")
docker_installer = DockerInstaller(global_common)
docker_installer.start()
return
closed_windows = {}
windows = {}
def delete_window(window_id):
closed_windows[window_id] = windows[window_id]
del windows[window_id]
# Open a document in a window
def select_document(filename=None):
if (
len(windows) == 1
and windows[list(windows.keys())[0]].common.document_filename == None
):
window = windows[list(windows.keys())[0]]
else:
window_id = uuid.uuid4().hex
window = MainWindow(global_common, window_id)
window.delete_window.connect(delete_window)
windows[window_id] = window
if filename:
# Validate filename
filename = os.path.abspath(os.path.expanduser(filename))
try:
open(filename, "rb")
except FileNotFoundError:
click.echo("File not found")
return False
except PermissionError:
click.echo("Permission denied")
return False
window.common.document_filename = filename
window.doc_selection_widget.document_selected.emit()
return True
# Open a new window if not filename is passed
if filename is None:
select_document()
else:
# If filename is passed as an argument, open it
if not select_document(filename):
return True
# Open a new window, if all windows are closed
def application_activated():
if len(windows) == 0:
select_document()
# If we get a file open event, open it
app.document_selected.connect(select_document)
# If the application is activated and all windows are closed, open a new one
app.application_activated.connect(application_activated)
sys.exit(app.exec_())

View file

@ -20,6 +20,7 @@ class Settings:
"open": True,
"open_app": default_pdf_viewer,
"update_container": True,
"linux_prefers_typing_password": None,
}
self.load()

View file

@ -141,7 +141,12 @@ class SettingsWidget(QtWidgets.QWidget):
self.update_checkbox.hide()
else:
output = subprocess.check_output(
[self.global_common.container_runtime, "image", "ls", self.global_common.get_container_name()],
self.global_common.get_dangerzone_container_args()
+ [
"image-ls",
"--container-name",
self.global_common.get_container_name(),
],
startupinfo=self.global_common.get_subprocess_startupinfo(),
)
if b"dangerzone" not in output:

View file

@ -16,12 +16,8 @@ class TaskBase(QtCore.QThread):
super(TaskBase, self).__init__()
def exec_container(self, args):
args = [self.global_common.container_runtime] + args
args_str = " ".join(pipes.quote(s) for s in args)
print()
print(f"Executing: {args_str}")
output = f"Executing: {args_str}\n\n"
args = self.global_common.get_dangerzone_container_args() + args
output = ""
self.update_details.emit(output)
with subprocess.Popen(
@ -38,9 +34,12 @@ class TaskBase(QtCore.QThread):
print(line, end="")
self.update_details.emit(output)
output += p.stderr.read()
stderr = p.stderr.read()
output += stderr
print(stderr)
self.update_details.emit(output)
print("")
return p.returncode, output
@ -55,7 +54,7 @@ class PullImageTask(TaskBase):
"Pulling container image (this might take a few minutes)"
)
self.update_details.emit("")
args = ["pull", "flmcode/dangerzone"]
args = ["pull"]
returncode, _ = self.exec_container(args)
if returncode != 0:
@ -78,15 +77,13 @@ class ConvertToPixels(TaskBase):
def run(self):
self.update_label.emit("Converting document to pixels")
args = [
"run",
"--network",
"none",
"-v",
f"{self.common.document_filename}:/tmp/input_file",
"-v",
f"{self.common.pixel_dir.name}:/dangerzone",
self.global_common.get_container_name(),
"document-to-pixels",
"--document-filename",
self.common.document_filename,
"--pixel-dir",
self.common.pixel_dir.name,
"--container-name",
self.global_common.get_container_name(),
]
returncode, output = self.exec_container(args)
@ -173,29 +170,27 @@ class ConvertToPDF(TaskBase):
self.update_label.emit("Converting pixels to safe PDF")
# Build environment variables list
envs = []
if self.global_common.settings.get("ocr"):
envs += ["-e", "OCR=1"]
ocr = "1"
else:
envs += ["-e", "OCR=0"]
envs += [
"-e",
f"OCR_LANGUAGE={self.global_common.ocr_languages[self.global_common.settings.get('ocr_language')]}",
ocr = "0"
ocr_lang = self.global_common.ocr_languages[
self.global_common.settings.get("ocr_language")
]
args = (
[
"run",
"--network",
"none",
"-v",
f"{self.common.pixel_dir.name}:/dangerzone",
"-v",
f"{self.common.safe_dir.name}:/safezone",
]
+ envs
+ [self.global_common.get_container_name(), "pixels-to-pdf",]
)
args = [
"pixels-to-pdf",
"--pixel-dir",
self.common.pixel_dir.name,
"--safe-dir",
self.common.safe_dir.name,
"--container-name",
self.global_common.get_container_name(),
"--ocr",
ocr,
"--ocr-lang",
ocr_lang,
]
returncode, output = self.exec_container(args)
if returncode != 0:

View file

@ -3,8 +3,10 @@
# Load dangerzone module and resources from the source code tree
import os, sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.dangerzone_dev = True
import dangerzone
dangerzone.main()
dangerzone.main()

View file

@ -0,0 +1 @@
dangerzone

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<policyconfig>
<action id="org.freedesktop.policykit.pkexec.dangerzone">
<description>Run Dangerzone Container</description>
<message>Dangerzone needs you to authenticate to run containers</message>
<defaults>
<allow_any>auth_admin_keep</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/dangerzone-container</annotate>
</action>
</policyconfig>

View file

@ -29,11 +29,20 @@ setuptools.setup(
["install/linux/media.firstlook.dangerzone.png"],
),
("share/dangerzone", file_list("share")),
(
"share/polkit-1/actions",
["install/linux/media.firstlook.dangerzone-container.policy"],
),
],
classifiers=[
"Programming Language :: Python",
"Intended Audience :: End Users/Desktop",
"Operating System :: OS Independent",
],
entry_points={"console_scripts": ["dangerzone = dangerzone:main"]},
entry_points={
"console_scripts": [
"dangerzone = dangerzone:main",
"dangerzone-container = dangerzone:container_main",
]
},
)