14 changes: 11 additions & 3 deletions data/org.x.Warpinator.gschema.xml
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,17 @@
<summary>Size (in kb) of each block of data sent over the network. Max is 4094</summary>
</key>
<key name="minimum-free-space" type="u">
<default>100</default>
<summary>Minimum free space (in mb) allowed in the save location</summary>
<description>If a transfer would cause the save location's free space to drop below this value, the transfer is refused</description>
<default>250</default>
<summary>Minimum free space (in mb) to reserve in the save location</summary>
<description>If the available space on the incoming folder's drive goes below this, existing transfers will be aborted. You will also be notified if a new transfer might cause the available space to drop below this threshold.</description>
</key>
<key name="group-code" type="s">
<default>'Warpinator'</default>
<summary>The group code to share between machines that wish to connect.</summary>
</key>
<key name="connect-id" type="s">
<default>''</default>
<summary>The unique identifier used for discovery on the network, starting with your hostname. This is automatically generated, though it can be changed (max length is 63 characters).</summary>
</key>

</schema>
Expand Down
15 changes: 15 additions & 0 deletions data/org.x.Warpinator.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
<!-- GDBus 2.48.1 -->
<node>
<interface name='org.x.Warpinator'>
<method name='ListRemotes'>
<!-- ident, display_name, user_name, hostname -->
<arg type='a(ssss)' name='remotes' direction='out'/>
</method>
<method name='SendFiles'>
<arg type='s' name='remote_uuid' direction='in'/>
<arg type='as' name='uri_list' direction='in'/>
</method>
</interface>
</node>
29 changes: 29 additions & 0 deletions data/warpinator-send-check
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/python3

import sys
import json
import subprocess

from gi.repository import Gio, GLib

try:
reply = Gio.DBusConnection.call_sync(
Gio.bus_get_sync(Gio.BusType.SESSION, None),
"org.x.Warpinator",
"/org/x/Warpinator",
"org.x.Warpinator",
"ListRemotes",
None,
GLib.VariantType("(aa{sv})"),
Gio.DBusCallFlags.NO_AUTO_START,
2000,
None
)

remote_list = reply[0]
exit(0 if len(remote_list) > 0 else 1)
except GLib.Error as e:
if e.code != Gio.DBusError.NAME_HAS_NO_OWNER:
print("warpinator-send-check error %d: %s" % (e.code, e.message), file=sys.stderr)

exit(1)
9 changes: 9 additions & 0 deletions data/warpinator-send.nemo_action.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[Nemo Action]
Active=true
Name=Send with Warpinator
Comment=Send a file to someone using Warpinator
Exec=warpinator-send --xid %X %U
Selection=notnone
Extensions=any
Icon-Name=org.x.Warpinator-symbolic
Conditions=exec <warpinator-send-check>;
29 changes: 29 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
warpinator (1.6.0) vera; urgency=medium

[ Michael Webster ]
* build: Fix deprecation warning.
* notifications: Fix indentation.
* Improve some logging.
* remote: Improve readability of rpc calls.
* Migrate group code and connect ids to gsettings.
* Include the python landlock module.
* Implement incoming folder isolation.
* Improve incoming file path validation.
* Forbid some locations from being chosen as the save folder.
* Check if recents can be written to before attempting it.
* Move some util functions into a new file.
* Add warpinator-send utility.
* bubblewrap: Fixes for debian/lmde.
* transfers.py: Use generic getter for file content type.
* build: Fix dh_python3 byte-compilation.
* Simplify startup scripts.
* Remove some remnants of a previous grpc version.
* free space monitor: Improve readability, comments.
* free space: Don't run the monitor when only sending files.
* Clean up --help information, add a new section to the README to explain landlock, bubblewrap.
* Add an infobar for a sandbox warning.
* Cleanup bubblewrap arguments, sandbox_mode setting, exit if the user specifies a mode that isn't available, explain file manager launch complexities.
* Simpliy NewThreadExecutor a bit.

-- Clement Lefebvre <root@linuxmint.com> Mon, 24 Apr 2023 11:44:04 +0100

warpinator (1.4.5) vera; urgency=medium

[ Michael Webster ]
Expand Down
4 changes: 4 additions & 0 deletions debian/py3dist-overrides
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Ignore Requires-Dist in our bundled grpc

futures python3-futures
enum34 python3-enum34
6 changes: 5 additions & 1 deletion debian/rules
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@

override_dh_auto_configure:
dh_auto_configure -- \
-Dbundle-grpc-with-py310=true
-Dbundle-grpc-with-py310=true

override_dh_python3:
dh_python3 usr/libexec/warpinator

3 changes: 0 additions & 3 deletions install-scripts/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,6 @@ meson.add_install_script('meson_install_schemas.py')
# Update the Gtk icon cache
meson.add_install_script('meson_update_icon_cache.py')

# Make the binfile executable
meson.add_install_script('meson_install_bin_script.sh')

# Flatpak already bundles.
if bundle_zeroconf and not get_option('flatpak-build')
meson.add_install_script('download_zeroconf.py')
Expand Down
3 changes: 0 additions & 3 deletions install-scripts/meson_install_bin_script.sh

This file was deleted.

2 changes: 2 additions & 0 deletions makepot
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ xgettext --package-name=warpinator --language=Python -c --join-existing --add-co
--output=warpinator.pot --files-from=src/gettext_files
xgettext --package-name=warpinator --language=Desktop --join-existing \
-k --keyword=Comment --output=warpinator.pot data/org.x.Warpinator.desktop.in.in
xgettext --package-name=warpinator --language=Desktop --join-existing \
--output=warpinator.pot data/warpinator-send.nemo_action.in
xgettext --package-name=warpinator --its=/usr/share/gettext/its/polkit.its --join-existing --add-comments \
--output=warpinator.pot data/org.x.warpinator.policy.in.in
xgettext --package-name=warpinator --its=/usr/share/gettext/its/metainfo.its --join-existing --add-comments \
Expand Down
8 changes: 6 additions & 2 deletions meson.build
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
project('warpinator', 'c', version: '1.4.5', meson_version: '>=0.47.0')
project('warpinator', 'c', version: '1.6.0', meson_version: '>=0.47.0')

RPC_API_VERSION = '2'

Expand All @@ -10,8 +10,11 @@ install_datadir = join_paths(get_option('prefix'), get_option('datadir'), 'warpi
install_libdir = join_paths(get_option('prefix'), get_option('libexecdir'), 'warpinator')
install_bindir = join_paths(get_option('prefix'), get_option('bindir'))

meson_source_root = meson.current_source_dir()

include_firewall_mod = get_option('include-firewall-mod')
bundle_zeroconf = get_option('bundle-zeroconf')
bundle_landlock = get_option('bundle-landlock')

# grpc 1.30.2 on ubuntu 22.04 is broken
pymod = import('python')
Expand All @@ -32,11 +35,12 @@ message('\n'.join(['',
' @0@-@1@'.format(meson.project_name(), meson.project_version()),
'',
' prefix: @0@'.format(get_option('prefix')),
' source code location: @0@'.format(meson.source_root()),
' source code location: @0@'.format(meson_source_root),
'',
' building for flatpak: @0@'.format(get_option('flatpak-build')),
' including ufw script: @0@'.format(include_firewall_mod),
' bundling zeroconf: @0@'.format(bundle_zeroconf and not get_option('flatpak-build')),
' bundling landlock: @0@'.format(bundle_landlock),
' bundling grpc: @0@'.format(bundle_grpc),
'',
]))
6 changes: 6 additions & 0 deletions meson_options.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ option('bundle-zeroconf',
description: 'python-zeroconf is actively developed, with api changes/breaks occasionally - it is safer to bundle a static version where permitted.'
)

option('bundle-landlock',
type: 'boolean',
value: true,
description: 'Include the landlock python module (https://github.com/Edward-Knight/landlock).'
)

option('bundle-grpc-with-py310',
type: 'boolean',
value: false,
Expand Down
372 changes: 353 additions & 19 deletions resources/main-window.ui

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion resources/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ data = [
'main-window.ui',
'op-item.ui',
'overview-button.ui',
'prefs-window.ui',
'prefs-window.ui'
]

install_data(data,
Expand Down
137 changes: 8 additions & 129 deletions src/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,7 @@
day = datetime.timedelta(1, 0, 0)
EXPIRE_TIME = 30 * day

DEFAULT_GROUP_CODE = "Warpinator"
KEYFILE_GROUP_NAME = "warpinator"
KEYFILE_CODE_KEY = "code"
KEYFILE_UUID_KEY = "connect_id"
CONFIG_FILE_NAME = ".group"

CONFIG_FOLDER = os.path.join(GLib.get_user_config_dir(), "warpinator")
os.makedirs(CONFIG_FOLDER, exist_ok=True)

singleton = None
keyfile = None

def get_singleton():
global singleton
Expand All @@ -47,79 +37,6 @@ def get_singleton():

return singleton

def _ensure_keyfile_loaded():
global keyfile

if keyfile is not None:
return

path = Path(os.path.join(CONFIG_FOLDER, CONFIG_FILE_NAME))
keyfile = GLib.KeyFile()

try:
keyfile.load_from_file(path.as_posix(), GLib.KeyFileFlags.NONE)
except GLib.Error as e:
if e.code == GLib.FileError.NOENT:
logging.debug("Auth: No group code file, making one.")
path.touch()
else:
logging.debug("Auth: Could not load existing keyfile (%s): %s" %(CONFIG_FOLDER, e.message))
path.unlink()
path.touch()

def _save_keyfile():
_ensure_keyfile_loaded()
global keyfile

keyfile_bytes = bytes(keyfile.to_data()[0], "utf-8")

path = Path(os.path.join(CONFIG_FOLDER, CONFIG_FILE_NAME))

try:
path.unlink()
except OSError:
pass

flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
mode = stat.S_IRUSR | stat.S_IWUSR
umask = 0o777 ^ mode # Prevents always downgrading umask to 0.

umask_original = os.umask(umask)

try:
fdesc = os.open(path, flags, mode)
finally:
os.umask(umask_original)

with os.fdopen(fdesc, 'wb') as f:
f.write(keyfile_bytes)

def get_group_code():
reset = False
code = None

_ensure_keyfile_loaded()
global keyfile

try:
code = keyfile.get_string(KEYFILE_GROUP_NAME, KEYFILE_CODE_KEY)
except GLib.Error as e:
if e.code not in (GLib.KeyFileError.KEY_NOT_FOUND, GLib.KeyFileError.GROUP_NOT_FOUND):
logging.warn("Could not read group code from settings file (%s): %s" % (CONFIG_FOLDER, e.message))

if code is None or code == "":
code = DEFAULT_GROUP_CODE
_save_keyfile()
return code

if len(code) < 4:
logging.warn("Group Code is short, consider something longer than 8 characters.")

return code

def get_secure_mode():
return get_group_code() != DEFAULT_GROUP_CODE

class AuthManager(GObject.Object):
__gsignals__ = {
'group-code-changed': (GObject.SignalFlags.RUN_LAST, None, ())
Expand All @@ -128,39 +45,25 @@ class AuthManager(GObject.Object):
def __init__(self):
GObject.Object.__init__(self)
self.hostname = util.get_hostname()
self.ident = ""
self.ip_info = None
self.port = None
self.code = ""

self.private_key = None
self.server_cert = None

self.remote_certs = {}

prefs.prefs_settings.connect("changed::group-code", self.notify_group_code_changed)

def notify_group_code_changed(self, settings, key, data=None):
self.emit("group-code-changed")

def update(self, ip_info, port):
self.ip_info = ip_info;
self.port = port

self._read_ident()
self.code = get_group_code()
self._make_key_cert_pair()

def get_ident(self):
return self.ident

def update_group_code(self, code):
if code == self.code:
return

_ensure_keyfile_loaded()

self.code = code
keyfile.set_string(KEYFILE_GROUP_NAME, KEYFILE_CODE_KEY, code)
_save_keyfile()

self.emit("group-code-changed")

def get_server_creds(self):
return (self.server_private_key, self.server_pub_key)

Expand All @@ -176,14 +79,14 @@ def process_remote_cert(self, hostname, ip_info, server_data):
decoded = base64.decodebytes(server_data)

hasher = hashlib.sha256()
hasher.update(bytes(self.code, "utf-8"))
hasher.update(bytes(prefs.get_group_code(), "utf-8"))
key = hasher.digest()
decoder = secret.SecretBox(key)

try:
cert = decoder.decrypt(decoded)
except nacl.exceptions.CryptoError as e:
print(e)
logging.debug("Decryption failed for remote '%s': %s" % (hostname, str(e)))
cert = None

if cert:
Expand All @@ -194,7 +97,7 @@ def process_remote_cert(self, hostname, ip_info, server_data):

def get_encoded_local_cert(self):
hasher = hashlib.sha256()
hasher.update(bytes(self.code, "utf-8"))
hasher.update(bytes(prefs.get_group_code(), "utf-8"))
key = hasher.digest()

encoder = secret.SecretBox(key)
Expand All @@ -203,30 +106,6 @@ def get_encoded_local_cert(self):
encoded = base64.encodebytes(encrypted)
return encoded

def _read_ident(self):
gen_new = False

_ensure_keyfile_loaded()
global keyfile

try:
self.ident = keyfile.get_string(KEYFILE_GROUP_NAME, KEYFILE_UUID_KEY)
except GLib.Error as e:
if e.code not in (GLib.KeyFileError.KEY_NOT_FOUND, GLib.KeyFileError.GROUP_NOT_FOUND):
logging.critical("Could not read uuid (ident) from settings file: %s" % e.message)

gen_new = True

if len(self.ident.split("-")) == 5:
gen_new = True

if gen_new:
# Max 'instance' length is 63.
# https://datatracker.ietf.org/doc/html/rfc6763#section-7.2
self.ident = "%s-%s" % (self.hostname.upper()[:42], secrets.token_hex(10).upper())
keyfile.set_string(KEYFILE_GROUP_NAME, KEYFILE_UUID_KEY, self.ident)
_save_keyfile()

def _make_key_cert_pair(self):
logging.debug("Auth: Creating server credentials")

Expand Down
6 changes: 6 additions & 0 deletions src/config.py.in
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@ VERSION="@VERSION@"
GETTEXT_PACKAGE="@gettext_package@"
FLATPAK_BUILD=@flatpak_build@
RPC_API_VERSION=@RPC_API_VERSION@



###################### This is not a constant #########################
# sandbox_mode is set in warpinator.py __main__ at launch.
sandbox_mode = "legacy"
73 changes: 73 additions & 0 deletions src/dbus_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/python3

import logging

from gi.repository import Gio, GObject, GLib

interface_xml = """
<node>
<interface name='org.x.Warpinator'>
<method name='ListRemotes'>
<!-- ident, display_name, user_name, hostname -->
<arg type='aa{sv}' name='remotes' direction='out'/>
</method>
<method name='SendFiles'>
<arg type='s' name='remote_uuid' direction='in'/>
<arg type='as' name='uri_list' direction='in'/>
</method>
</interface>
</node>
"""
interface_node_info = Gio.DBusNodeInfo.new_for_xml(interface_xml)

class WarpinatorDBusService(GObject.Object):
__gsignals__ = {
'handle-get-live-remotes': (GObject.SignalFlags.RUN_LAST, object, ()),
'handle-send-files': (GObject.SignalFlags.RUN_LAST, None, (str, object))
}

def __init__(self):
GObject.Object.__init__(self)
self.reg_id = 0

def register(self, connection, path):
self.reg_id = connection.register_object(
path,
interface_node_info.interfaces[0],
self._method_cb,
None,
None
)

def unregister(self, connection, path):
if self.reg_id > 0:
connection.unregister_object(self.reg_id)
self.reg_id = 0

def _method_cb(self, connection, sender, path, iface_name, method_name, params, invocation, user_data=None):
if method_name == "ListRemotes":
remotes = self.emit("handle-get-live-remotes")

builder = GLib.VariantBuilder(GLib.VariantType("aa{sv}"))

for remote in remotes:
vdict = GLib.VariantDict.new()
vdict.insert_value("uuid", GLib.Variant.new_string(remote.ident))
vdict.insert_value("display-name", GLib.Variant.new_string(remote.display_name))
vdict.insert_value("username", GLib.Variant.new_string(remote.user_name))
vdict.insert_value("hostname", GLib.Variant.new_string(remote.display_hostname))
vdict.insert_value("favorite", GLib.Variant.new_boolean(remote.favorite))
vdict.insert_value("recent-time", GLib.Variant.new_int64(remote.recent_time))
builder.add_value(vdict.end())
props = builder.end()

logging.debug("Received DBus call 'ListRemotes'. Returning: %s\n" % props)
invocation.return_value(GLib.Variant.new_tuple(props))
elif method_name == "SendFiles":
ident, files = params.unpack()

logging.debug("Received DBus call 'SendFiles'.\nRecipient: %s\nFiles: %s\n" % (ident, str(files)))

self.emit("handle-send-files", *params.unpack())
invocation.return_value(None)

202 changes: 0 additions & 202 deletions src/grpc-py310-x86_64/grpcio-1.41.1.dist-info/LICENSE

This file was deleted.

143 changes: 0 additions & 143 deletions src/grpc-py310-x86_64/grpcio-1.41.1.dist-info/METADATA

This file was deleted.

61 changes: 0 additions & 61 deletions src/grpc-py310-x86_64/grpcio-1.41.1.dist-info/RECORD

This file was deleted.

6 changes: 0 additions & 6 deletions src/grpc-py310-x86_64/grpcio-1.41.1.dist-info/WHEEL

This file was deleted.

This file was deleted.

31 changes: 31 additions & 0 deletions src/landlock/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
The MIT License (MIT)

Copyright (c) 2022 Edward Knight

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.


Files:

__init__.py
plumbing.py
porcelain.py

ref:
https://github.com/Edward-Knight/landlock
43 changes: 43 additions & 0 deletions src/landlock/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Python interface to the Landlock Linux Security Module."""
from typing import Optional

__version__ = "1.0.0.dev4"


class LandlockError(Exception):
"""Generic exception for this module."""


class SyscallError(OSError, LandlockError):
"""Exception raised from a syscall."""

def __init__(self, *args, reason: Optional[str] = None):
"""Augments an OSError with a "reason".
This is similar to BaseException.add_note() from Python 3.11.
"""
self.reason = reason
super().__init__(*args)

def __str__(self):
super_str = super().__str__()
if self.reason is not None:
return super_str + f"\nNote: {self.reason}"
return super_str

def __repr__(self):
return (
f"{self.__class__.__name__}({', '.join(self.args)}, reason={self.reason})"
)


from landlock.plumbing import FSAccess, landlock_abi_version # noqa E402
from landlock.porcelain import Ruleset # noqa E402

__all__ = [
"FSAccess",
"landlock_abi_version",
"LandlockError",
"Ruleset",
"SyscallError",
]
306 changes: 306 additions & 0 deletions src/landlock/plumbing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
"""Landlock constants and syscalls."""
import ctypes
import enum
import errno
import functools
import os
import platform
from typing import Callable, Optional, Tuple, TypeVar

import _ctypes

from landlock import SyscallError

CREATE_RULESET_VERSION = 1 << 0

SYSCALL_CREATE_RULESET = 444
SYSCALL_ADD_RULE = 445
SYSCALL_RESTRICT_SELF = 446
PR_SET_NO_NEW_PRIVS = 38

T = TypeVar("T")


class FSAccess(enum.IntFlag):
"""Flags representing types of file system actions.
These flags enable one to restrict a sandboxed process
to a set of actions on files and directories.
Files or directories opened before the sandboxing
are not subject to these restrictions.
"""

# A file can only receive these access rights:
EXECUTE = 1 << 0
"""Execute a file."""
WRITE_FILE = 1 << 1
"""Open a file with write access."""
READ_FILE = 1 << 2
"""Open a file with read access."""

# A directory can receive access rights related to files or directories.
# The following access right is applied to the directory itself,
# and the directories beneath it:
READ_DIR = 1 << 3
"""Open a directory or list its content."""

# However, the following access rights only apply to the content of a directory,
# not the directory itself:
REMOVE_DIR = 1 << 4
"""Remove an empty directory or rename one."""
REMOVE_FILE = 1 << 5
"""Unlink (or rename) a file."""
MAKE_CHAR = 1 << 6
"""Create (or rename or link) a character device."""
MAKE_DIR = 1 << 7
"""Create (or rename) a directory."""
MAKE_REG = 1 << 8
"""Create (or rename or link) a regular file."""
MAKE_SOCK = 1 << 9
"""Create (or rename or link) a UNIX domain socket."""
MAKE_FIFO = 1 << 10
"""Create (or rename or link) a named pipe."""
MAKE_BLOCK = 1 << 11
"""Create (or rename or link) a block device."""
MAKE_SYM = 1 << 12
"""Create (or rename or link) a symbolic link."""
REFER = 1 << 13
"""Link or rename a file from or to a different directory.
I.e. reparent a file hierarchy.
Only available if the ABI version >= 2.
"""

@classmethod
def all(cls):
return cls.all_file() | cls.all_dir()

@classmethod
def all_file(cls):
return cls.EXECUTE | cls.WRITE_FILE | cls.READ_FILE

@classmethod
def all_dir(cls):
flags = (
cls.READ_DIR
| cls.REMOVE_DIR
| cls.REMOVE_FILE
| cls.MAKE_CHAR
| cls.MAKE_DIR
| cls.MAKE_REG
| cls.MAKE_SOCK
| cls.MAKE_FIFO
| cls.MAKE_BLOCK
| cls.MAKE_SYM
)
# REFER only available in version 2
if landlock_abi_version() >= 2:
flags |= cls.REFER
return flags


class RulesetAttr(ctypes.Structure):
_fields_ = [("handled_access_fs", ctypes.c_uint64)]


class PathBeneathAttr(ctypes.Structure):
_fields_ = [
("allowed_access", ctypes.c_uint64),
("parent_fd", ctypes.c_int32),
]


def find_generic_reason_from_platform() -> Optional[str]:
# check OS
system = platform.system()
if system != "Linux":
return (
f"Landlock is a only available on Linux,"
f" it looks like you're running {system}"
)

# check kernel version
kernel_version = platform.release()
kernel_version_tuple = tuple(map(int, kernel_version.split("-")[0].split(".")))
if kernel_version_tuple < (5, 13):
return (
f"Landlock is only available in kernel 5.13 or newer,"
f" it looks like you're running {kernel_version}"
)

return None


def find_generic_reason_from_errno(err: int) -> Optional[str]:
errno_to_reason = {
errno.ENOSYS: "Cannot find Landlock syscalls - perhaps the kernel was not built"
" with 'CONFIG_SECURITY_LANDLOCK=y'",
errno.EOPNOTSUPP: "Landlock has been disabled at boot time."
" The CONFIG_LSM configuration item should list 'landlock',"
" or, 'lsm=landlock' needs to be added the kernel's"
" command-line arguments (usually via your bootloader).",
}
return errno_to_reason.get(err)


def syscall_errcheck(
result: T, func: _ctypes.CFuncPtr, arguments: Tuple, reason: Optional[str] = None
) -> T:
err = ctypes.get_errno()

# return the result if there was no error
if err == 0:
return result

# otherwise raise a SyscallError exception
name = getattr(func, "name", func.__name__)
reason = (
find_generic_reason_from_platform()
or find_generic_reason_from_errno(err)
or reason
)
raise SyscallError(
err,
os.strerror(err),
f"Error calling {name}: {func.__name__}{arguments} = {result}",
reason=reason,
)


def create_ruleset_errcheck(result: T, func: _ctypes.CFuncPtr, arguments: Tuple) -> T:
errno_to_reason = {
errno.EINVAL: "Unknown 'flags', or unknown access, or too small 'size'",
errno.E2BIG: "'attr' or 'size' inconsistencies",
errno.EFAULT: "'attr' or 'size' inconsistencies",
errno.ENOMSG: "Empty 'landlock_ruleset_attr.handled_access_fs'",
}
return syscall_errcheck(
result, func, arguments, errno_to_reason.get(ctypes.get_errno())
)


def add_rule_errcheck(result: T, func: _ctypes.CFuncPtr, arguments: Tuple) -> T:
errno_to_reason = {
errno.EINVAL: "'flags' is not 0, or inconsistent access in the rule"
" (i.e. 'landlock_path_beneath_attr.allowed_access'"
" is not a subset of the ruleset handled accesses)",
errno.ENOMSG: "Empty accesses"
" (e.g. 'landlock_path_beneath_attr.allowed_access')",
errno.EBADF: "'ruleset_fd' is not a file descriptor for the current thread,"
" or a member of 'rule_attr' is not a file descriptor as expected",
errno.EBADFD: "'ruleset_fd' is not a ruleset file descriptor,"
" or a member of 'rule_attr' is not the expected file descriptor type",
errno.EPERM: "'ruleset_fd' has no write access to the underlying ruleset",
errno.EFAULT: "'rule_attr' inconsistency",
}
return syscall_errcheck(
result, func, arguments, errno_to_reason.get(ctypes.get_errno())
)


def restrict_self_errcheck(result: T, func: _ctypes.CFuncPtr, arguments: Tuple) -> T:
errno_to_reason = {
errno.EINVAL: "'flags' is not 0",
errno.EBADF: "'ruleset_fd' is not a file descriptor for the current thread",
errno.EBADFD: "'ruleset_fd' is not a ruleset file descriptor",
errno.EPERM: "'ruleset_fd' has no read access to the underlying ruleset,"
" or the current thread is not running with no_new_privs,"
" or it doesn’t have 'CAP_SYS_ADMIN' in its namespace",
errno.E2BIG: "The maximum number of stacked rulesets (16)"
" has been reached for the current thread",
}
return syscall_errcheck(
result, func, arguments, errno_to_reason.get(ctypes.get_errno())
)


@functools.lru_cache(1)
def get_libc() -> ctypes.CDLL:
try:
return ctypes.CDLL(None, use_errno=True)
except TypeError as e:
if platform.system() == "Windows":
# on Windows we get a TypeError using name=None
raise SyscallError(reason=find_generic_reason_from_platform()) from e
raise


@functools.lru_cache(1)
def get_create_ruleset() -> Callable:
libc = get_libc()

create_ruleset = functools.partial(libc["syscall"], SYSCALL_CREATE_RULESET)
create_ruleset.func.name = "landlock_create_ruleset"
create_ruleset.func.argtypes = (
ctypes.c_long,
ctypes.POINTER(RulesetAttr),
ctypes.c_size_t,
ctypes.c_uint32,
)
create_ruleset.func.errcheck = create_ruleset_errcheck
create_ruleset.func.restype = ctypes.c_long

return create_ruleset


@functools.lru_cache(1)
def get_add_rule() -> Callable:
libc = get_libc()

add_rule = functools.partial(libc["syscall"], SYSCALL_ADD_RULE)
add_rule.func.name = "landlock_add_rule"
add_rule.func.argtypes = (
ctypes.c_long,
ctypes.c_int,
ctypes.c_uint,
ctypes.POINTER(PathBeneathAttr),
ctypes.c_uint32,
)
add_rule.func.errcheck = add_rule_errcheck
add_rule.func.restype = ctypes.c_long

return add_rule


@functools.lru_cache(1)
def get_restrict_self() -> Callable:
libc = get_libc()

restrict_self = functools.partial(libc["syscall"], SYSCALL_RESTRICT_SELF)
restrict_self.func.name = "landlock_restrict_self"
restrict_self.func.argtypes = (
ctypes.c_long,
ctypes.c_int,
ctypes.c_uint32,
)
restrict_self.func.errcheck = restrict_self_errcheck
restrict_self.func.restype = ctypes.c_long

return restrict_self


@functools.lru_cache(1)
def get_prctl() -> Callable:
# https://man7.org/linux/man-pages/man2/prctl.2.html
libc = get_libc()

prctl = libc.prctl
prctl.name = "prctl"
prctl.argtypes = (
ctypes.c_int,
ctypes.c_ulong,
ctypes.c_ulong,
ctypes.c_ulong,
ctypes.c_ulong,
)
prctl.errcheck = syscall_errcheck
prctl.restype = ctypes.c_int

return libc.prctl


@functools.lru_cache(1)
def landlock_abi_version() -> int:
return get_create_ruleset()(None, 0, CREATE_RULESET_VERSION)
54 changes: 54 additions & 0 deletions src/landlock/porcelain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import ctypes
import dataclasses
import os
from typing import Optional

from landlock import LandlockError
from landlock.plumbing import (
PR_SET_NO_NEW_PRIVS,
FSAccess,
PathBeneathAttr,
RulesetAttr,
get_add_rule,
get_create_ruleset,
get_prctl,
get_restrict_self,
)


@dataclasses.dataclass(frozen=True)
class Ruleset:
restrict_rules: FSAccess = dataclasses.field(default_factory=FSAccess.all)
_fd: int = dataclasses.field(init=False)

def __post_init__(self):
ruleset_attr = RulesetAttr(self.restrict_rules)
fd = get_create_ruleset()(
ctypes.byref(ruleset_attr),
ctypes.sizeof(ruleset_attr),
0,
)
object.__setattr__(self, "_fd", fd)

def allow(self, *paths, rules: Optional[FSAccess] = None):
if rules is None:
rules = self.restrict_rules

for path in paths:
fd = os.open(path, flags=os.O_PATH)
try:
rule_attr = PathBeneathAttr(rules, fd)
get_add_rule()(self._fd, 1, ctypes.byref(rule_attr), 0)
finally:
os.close(fd)

def apply(self):
# restrict thread from gaining privileges
try:
prctl = get_prctl()
except Exception as e:
raise LandlockError("Cannot find prctl libc function") from e
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)

# turn on Landlock restrictions
get_restrict_self()(self._fd, 0)
20 changes: 18 additions & 2 deletions src/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ config_py = configure_file(
libexec_py = [
config_py,
'auth.py',
'dbus_service.py',
'interceptors.py',
'misc.py',
'networkmonitor.py',
'notifications.py',
'ops.py',
Expand All @@ -32,15 +34,21 @@ libexec_py = [
'server.py',
'transfers.py',
'util.py',
'warpinator.py',
'warp_pb2.py',
'warp_pb2_grpc.py'
'warp_pb2_grpc.py',
'warpinator.py',
]

install_data(libexec_py,
install_dir: install_libdir
)

install_data(
'warpinator-launch.py',
install_dir: install_libdir,
install_mode: 'rwxr-xr-x'
)

if include_firewall_mod
subdir('firewall')
endif
Expand All @@ -51,4 +59,12 @@ if bundle_grpc
install_dir: join_paths(install_libdir, 'grpc'),
strip_directory: true
)
endif

if bundle_landlock
install_subdir(
'landlock',
install_dir: join_paths(install_libdir, 'landlock'),
strip_directory: true
)
endif
30 changes: 30 additions & 0 deletions src/misc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/python3

import threading
import traceback

from gi.repository import GLib

EXIT_CODE_RESTART_BWRAP = 100

# Used as a decorator to run things in the background
def _async(func):
def wrapper(*args, **kwargs):
thread = threading.Thread(target=func, args=args, kwargs=kwargs)
thread.daemon = True
thread.start()
return thread
return wrapper

# Used as a decorator to run things in the main loop, from another thread
def _idle(func):
def wrapper(*args, **kwargs):
GLib.idle_add(func, *args, **kwargs)
return wrapper

def print_stack():
traceback.print_stack()

def check_ml(fid):
on_ml = threading.current_thread() == threading.main_thread()
print("%s on mainloop: " % fid, on_ml)
44 changes: 39 additions & 5 deletions src/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from gi.repository import Gio

import config
import misc
import util
from util import OpStatus
import prefs
Expand All @@ -17,7 +18,7 @@ def __init__(self, op):

self.send_notification()

@util._idle
@misc._idle
def send_notification(self):
if prefs.get_show_notifications():
notification = Gio.Notification.new(_("New incoming files"))
Expand Down Expand Up @@ -63,7 +64,7 @@ def send_notification(self):
notification.set_body(body)
notification.set_icon(Gio.ThemedIcon(name="org.x.Warpinator-symbolic"))

Gio.Application.get_default().send_notification(self.op.sender, notification)
Gio.Application.get_default().send_notification(self.op.sender, notification)

def _notification_response(self, action, variant, op):
response = variant.unpack()
Expand All @@ -86,7 +87,7 @@ def __init__(self, op, sender=True, warn=False):

self.send_notification()

@util._idle
@misc._idle
def send_notification(self):
if prefs.get_show_notifications():
notification = Gio.Notification.new(_("Transfer complete"))
Expand Down Expand Up @@ -130,7 +131,7 @@ def __init__(self, op, sender=True):

self.send_notification()

@util._idle
@misc._idle
def send_notification(self):
if prefs.get_show_notifications():
notification = Gio.Notification.new(_("Transfer failed"))
Expand Down Expand Up @@ -164,7 +165,7 @@ def __init__(self, op, sender=True):

self.send_notification()

@util._idle
@misc._idle
def send_notification(self):
if prefs.get_show_notifications():
notification = Gio.Notification.new(_("Transfer cancelled"))
Expand All @@ -190,3 +191,36 @@ def _notification_response(self, action, variant, op):

app = Gio.Application.get_default()
app.lookup_action("notification-response").disconnect_by_func(self._notification_response)

class WarpinatorSendNotification():
def __init__(self, op):
self.op = op

self.send_notification()

@misc._idle
def send_notification(self):
if prefs.get_show_notifications():
notification = Gio.Notification.new(_("Sending files"))

if self.op.total_count > 1:
body = (_("Sending %d files to %s") % (self.op.total_count, self.op.receiver_name))
else:
body = (_("Sending '%s' to %s") % (self.op.top_dir_basenames[0], self.op.sender_name))

notification.set_body(body)
notification.set_icon(Gio.ThemedIcon(name="dialog-info-symbolic"))
notification.set_default_action("app.notification-response::focus")

notification.set_priority(Gio.NotificationPriority.NORMAL)

app = Gio.Application.get_default()
app.lookup_action("notification-response").connect("activate", self._notification_response, self.op)

app.get_default().send_notification(self.op.sender, notification)

def _notification_response(self, action, variant, op):
op.focus()

app = Gio.Application.get_default()
app.lookup_action("notification-response").disconnect_by_func(self._notification_response)
39 changes: 31 additions & 8 deletions src/ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

import gettext
import logging
from pathlib import Path

from gi.repository import GObject, GLib, Gio

import grpc

import transfers
import prefs
import misc
import util
import notifications
from util import OpStatus, OpCommand, TransferDirection, ReceiveError
Expand All @@ -34,6 +36,8 @@ def __init__(self, direction, sender, uris=None):

self.total_size = 0
self.total_count = 0
self.remaining_count = 0

self.size_string = ""
self.description = ""
self.name_if_single = None
Expand Down Expand Up @@ -86,11 +90,11 @@ def set_error(self, e=None):
else:
self.error_msg = str(e)

@util._idle
@misc._idle
def emit_initial_setup_complete(self):
self.emit("initial-setup-complete")

@util._idle
@misc._idle
def emit_status_changed(self):
self.emit("status-changed")

Expand All @@ -106,7 +110,7 @@ def __init__(self, sender=None, receiver=None, receiver_name=None, uris=None):
self.receiver = receiver
self.sender_name = GLib.get_real_name()
self.receiver_name = receiver_name

self.dbus_op = False
self.resolved_files = []
self.first_missing_file = None

Expand All @@ -124,7 +128,7 @@ def set_status(self, status):

if status == OpStatus.FINISHED:
notifications.TransferCompleteNotification(self, sender=True)
elif status in (OpStatus.FAILED_UNRECOVERABLE, OpStatus.FAILED):
elif status in (OpStatus.FAILED_UNRECOVERABLE, OpStatus.FAILED, OpStatus.FILE_NOT_FOUND):
notifications.TransferFailedNotification(self, sender=True)
# We only care if the other remote cancelled. If we did it, we don't need a notification.
elif status == OpStatus.STOPPED_BY_RECEIVER:
Expand Down Expand Up @@ -157,15 +161,23 @@ def update_ui_info(self, error):
self.set_status(OpStatus.WAITING_PERMISSION)
else:
if isinstance(error, GLib.Error) and error.code == Gio.IOErrorEnum.NOT_FOUND:
self.status = OpStatus.FILE_NOT_FOUND
# self.status = OpStatus.FILE_NOT_FOUND
self.description = ""
self.error_msg = ""
self.first_missing_file = self.top_dir_basenames[-1]
try:
self.first_missing_file = self.top_dir_basenames[0]
except IndexError:
self.first_missing_file = None
self.gicon = Gio.ThemedIcon.new("dialog-error-symbolic")
self.set_status(OpStatus.FILE_NOT_FOUND)
else:
self.status = OpStatus.FAILED_UNRECOVERABLE
# self.status = OpStatus.FAILED_UNRECOVERABLE
self.description = ""
self.set_error(error)
self.set_status(OpStatus.FAILED_UNRECOVERABLE)

if self.dbus_op and self.status == OpStatus.WAITING_PERMISSION:
notifications.WarpinatorSendNotification(self)

self.emit_initial_setup_complete()
self.emit_status_changed()
Expand Down Expand Up @@ -228,7 +240,18 @@ def prepare_receive_info(self):
self.size_string = GLib.format_size(self.total_size)
logging.debug("Op: details: %d files, with a size of %s" % (self.total_count, self.size_string))

self.have_space = util.have_free_space(self.total_size)
# Check that toplevels are valid, safe. This is done immediately to prevent some sort of runaway
# free-space check.
for top_dir in self.top_dir_basenames:
try:
util.test_resolved_path_safety(top_dir)
except ReceiveError as e:
self.set_error(e)
self.status = OpStatus.FAILED_UNRECOVERABLE
self.emit_initial_setup_complete()
return

self.have_space = util.free_space_monitor.have_enough_free(self.total_size, self.top_dir_basenames)
self.existing = util.files_exist(self.top_dir_basenames)
self.update_ui_info()

Expand Down
168 changes: 124 additions & 44 deletions src/prefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@
import logging
import json
import re
import secrets
import cairo
from pathlib import Path

from xapp.GSettingsWidgets import GSettingsSwitch, GSettingsFileChooser, GSettingsComboBox
from xapp.GSettingsWidgets import GSettingsSwitch, GSettingsFileChooser, GSettingsComboBox, GSettingsSpinButton
from xapp.SettingsWidgets import SettingsWidget, SettingsPage, SettingsStack, SpinButton, Entry, Button, ComboBox
from gi.repository import Gtk, Gdk, Gio, GLib

import config
import auth
import util
import misc
import networkmonitor

_ = gettext.gettext
Expand All @@ -40,54 +43,78 @@
COMPRESSION_LEVEL_KEY = "zlib-compression-level"
BLOCK_SIZE_KEY = "transfer-block-size"
MIN_FREE_SPACE_KEY = "minimum-free-space"
GROUP_CODE_KEY = "group-code"
CONNECT_ID_KEY = "connect-id"

DEFAULT_GROUP_CODE = "Warpinator"

prefs_settings = Gio.Settings(schema_id=PREFS_SCHEMA)

#### Secure mode
class SecureModePrefsBlocker():
# This prevents external changes to Warpinator (like from a terminal or dconf-editor) while warpinator
# is running
def __init__(self):
self.active = False
self.settings_changed_id = 0
## Migrate ~/.config/warpinator/.group

def set_active(self, active):
if self.active == active:
return
KEYFILE_GROUP_NAME = "warpinator"
KEYFILE_CODE_KEY = "code"
KEYFILE_UUID_KEY = "connect_id"
CONFIG_FILE_NAME = ".group"
CONFIG_FOLDER = Path(os.path.join(GLib.get_user_config_dir(), "warpinator"))
path = Path(os.path.join(CONFIG_FOLDER, CONFIG_FILE_NAME))

if self.settings_changed_id > 0:
prefs_settings.disconnect(self.settings_changed_id)
self.settings_changed_id = 0
def get_new_connect_id():
return "%s-%s" % (util.get_hostname().upper()[:42], secrets.token_hex(10).upper())

if active:
self.settings_changed_id = prefs_settings.connect("changed", self._enforce_settings)
self._enforce_settings(self)
try:
keyfile = GLib.KeyFile()
keyfile.load_from_file(path.as_posix(), GLib.KeyFileFlags.NONE)

self.active = active
try:
code = keyfile.get_string(KEYFILE_GROUP_NAME, KEYFILE_CODE_KEY)

def _enforce_settings(self, settings=None, key=None):
prefs_settings.handler_block(self.settings_changed_id)
if code == None or code == "":
raise

prefs_settings.delay()
prefs_settings.set_boolean(AUTOSTART_KEY, False)
prefs_settings.set_boolean(ASK_PERMISSION_KEY, True)
prefs_settings.set_boolean(NO_OVERWRITE_KEY, True)
prefs_settings.apply()
prefs_settings.sync() # when this is used in /usr/bin/warpinator to check for autostart, there's no main loop yet.
if len(code) < 4:
logging.warn("Group Code is short, consider something longer than 8 characters.")
except:
code = DEFAULT_GROUP_CODE

prefs_settings.handler_unblock(self.settings_changed_id)
try:
connect_id = keyfile.get_string(KEYFILE_GROUP_NAME, KEYFILE_UUID_KEY)
if len(connect_id.split("-")) == 5:
raise
except:
# Max 'instance' length is 63.
# https://datatracker.ietf.org/doc/html/rfc6763#section-7.2
connect_id = get_new_connect_id()

def enforce_secure_mode(self):
self.set_active(not auth.get_secure_mode())
prefs_settings.set_string(GROUP_CODE_KEY, code)
prefs_settings.set_string(CONNECT_ID_KEY, connect_id)

secure_mode_blocker = SecureModePrefsBlocker()
secure_mode_blocker.enforce_secure_mode()
path.unlink()

####
try:
path.parent.rmdir()
except (OSError, FileNotFoundError):
logging.warn("Could not remove obsolete group code file and directory at '%s' - maybe the directory isn't empty?")
except GLib.Error as e:
logging.debug("Migration failed - either migration already happened, or there was nothing to migrate in the first place: %s" % str(e))

## /migrate

# Sanity checks, initial values...
if prefs_settings.get_int(PORT_KEY) == prefs_settings.get_int(REG_PORT_KEY):
prefs_settings.set_int(REG_PORT_KEY, prefs_settings.get_int(PORT_KEY) + 1)

code = prefs_settings.get_string(GROUP_CODE_KEY)
if code == "":
prefs_settings.set_string(GROUP_CODE_KEY, DEFAULT_GROUP_CODE)
connect_id = prefs_settings.get_string(CONNECT_ID_KEY)
if connect_id == "":
prefs_settings.set_string(CONNECT_ID_KEY, get_new_connect_id())

prefs_settings.sync()

# /sanity

def get_should_autostart():
return prefs_settings.get_boolean(AUTOSTART_KEY)

Expand Down Expand Up @@ -191,6 +218,50 @@ def get_block_size():
def get_min_free_space():
return prefs_settings.get_uint(MIN_FREE_SPACE_KEY)

def get_group_code():
return prefs_settings.get_string(GROUP_CODE_KEY)

def get_secure_mode():
return get_group_code() != DEFAULT_GROUP_CODE

def get_connect_id():
return prefs_settings.get_string(CONNECT_ID_KEY)


#### Secure mode
class SecureModePrefsBlocker():
# This prevents external changes to Warpinator (like from a terminal or dconf-editor) while warpinator
# is running
def __init__(self):
self.active = False
self.blocker_settings = Gio.Settings(schema_id=PREFS_SCHEMA)
self.blocker_settings.delay()

self.settings_changed_id = 0

self._enforce_settings()

def _enforce_settings(self, settings=None, key=None):
if get_group_code() != DEFAULT_GROUP_CODE:
return

if self.settings_changed_id > 0:
self.blocker_settings.handler_block(self.settings_changed_id)

self.blocker_settings.set_boolean(AUTOSTART_KEY, False)
self.blocker_settings.set_boolean(ASK_PERMISSION_KEY, True)
self.blocker_settings.set_boolean(NO_OVERWRITE_KEY, True)
self.blocker_settings.apply()
self.blocker_settings.sync() # when this is used in /usr/bin/warpinator to check for autostart, there's no main loop yet.

if self.settings_changed_id > 0:
self.blocker_settings.handler_unblock(self.settings_changed_id)

def start_monitor(self):
self.settings_changed_id = self.blocker_settings.connect("changed", self._enforce_settings)

####

class Preferences():
def __init__(self, main_window, page_name):
self.builder = Gtk.Builder.new_from_file(os.path.join(config.pkgdatadir, "prefs-window.ui"))
Expand All @@ -210,7 +281,8 @@ def __init__(self, main_window, page_name):
self.content_box.pack_start(page_stack, True, True, 0)
self.page_switcher.set_stack(page_stack)

auth.get_singleton().connect("group-code-changed", self.on_group_code_changed)
prefs_settings.connect("changed::group-code", self.on_group_code_changed)

self.unsafe_options = []

# Settings
Expand Down Expand Up @@ -245,6 +317,11 @@ def __init__(self, main_window, page_name):
size_group=size_group, dir_select=True)
section.add_row(widget)

widget = GSettingsSpinButton(_("Reserved free space"),
PREFS_SCHEMA, MIN_FREE_SPACE_KEY, units="MB", mini=250, maxi=GLib.MAXUINT)

section.add_row(widget)

widget = GSettingsSwitch(_("Require approval before accepting files"),
PREFS_SCHEMA, ASK_PERMISSION_KEY)
self.unsafe_options.append(widget)
Expand Down Expand Up @@ -412,11 +489,14 @@ def lookup_name(iface, node):

section.add_row(widget)

self.on_group_code_changed(self)
self.on_group_code_changed(None, None)

self.window.show_all()
page_stack.set_visible_child_full(page_name, transition=Gtk.StackTransitionType.NONE)

def destroy(self):
GLib.timeout_add(1000, self.window.destroy)

def open_port(self, widget):
self.run_port_script(self.settings.get_int(PORT_KEY), self.settings.get_int(REG_PORT_KEY))

Expand Down Expand Up @@ -465,15 +545,15 @@ def apply_net_settings(self, widget, data=None):

self.settings.apply()

@util._async
@misc._async
def run_port_script(self, port, auth_port):
command = os.path.join(config.libexecdir, "firewall", "ufw-modify")
subprocess.run(["pkexec", command, str(port), str(auth_port)])

GLib.timeout_add_seconds(1, lambda: Gio.Application.get_default().firewall_script_finished())

def on_group_code_changed(self, singleton=None):
is_default_code = auth.get_group_code() == auth.DEFAULT_GROUP_CODE
def on_group_code_changed(self, settings, key):
is_default_code = not get_secure_mode()

for widget in self.unsafe_options:
if is_default_code:
Expand Down Expand Up @@ -508,8 +588,8 @@ class GroupCodeEntry(SettingsWidget):
def __init__(self, focus_entry=False):
super(GroupCodeEntry, self).__init__()

self.code = auth.get_group_code()
auth.get_singleton().connect("group-code-changed", self.on_group_code_changed)
self.code = get_group_code()
prefs_settings.connect("changed::group-code", self.on_group_code_changed)

self.builder = Gtk.Builder.new_from_file(os.path.join(config.pkgdatadir, "group-code.ui"))

Expand Down Expand Up @@ -548,7 +628,7 @@ def __init__(self, focus_entry=False):

self.status_bar.connect("draw", self.status_bar_draw)

self.on_group_code_changed(auth.get_singleton())
self.on_group_code_changed(None, None)

def text_changed(self, widget, data=None):
text = widget.get_text()
Expand All @@ -571,10 +651,10 @@ def text_changed(self, widget, data=None):
def set_code_clicked(self, widget, data=None):
self.code = self.entry.get_text()
self.set_code_button.set_sensitive(False)
auth.get_singleton().update_group_code(self.code)
prefs_settings.set_string(GROUP_CODE_KEY, self.code)

def status_bar_draw(self, widget, cr):
if auth.get_secure_mode():
if get_secure_mode():
color = self.secure_color
else:
color = self.insecure_color
Expand All @@ -592,8 +672,8 @@ def status_bar_draw(self, widget, cr):

return True

def on_group_code_changed(self, singleton):
if auth.get_secure_mode():
def on_group_code_changed(self, settings, key):
if get_secure_mode():
self.secure_mode_label.set_text(_("ON"))
self.reason_label.set_text(_("All options are unlocked."))
else:
Expand Down
118 changes: 74 additions & 44 deletions src/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import interceptors
import prefs
import util
import misc
import transfers
import auth
from ops import SendOp, ReceiveOp
Expand Down Expand Up @@ -242,12 +243,12 @@ def channel_state_changed(state):
self.rpc_call(self.update_remote_machine_avatar)

# Online loop
logging.info("Connected to %s" % self.display_hostname)
while not self.channel_keepalive.is_set():
self.channel_keepalive.wait(.5)
##

except Exception as e:
print("exception")
self.set_remote_status(RemoteStatus.UNREACHABLE)

if isinstance(e, grpc.FutureTimeoutError):
Expand Down Expand Up @@ -368,15 +369,23 @@ def get_info_finished(future):
self.emit_machine_info_changed()
self.set_remote_status(RemoteStatus.ONLINE)

future = self.stub.GetRemoteMachineInfo.future(warp_pb2.LookupName(id=self.local_ident,
readable_name=util.get_hostname()))
future = self.stub.GetRemoteMachineInfo.future(
warp_pb2.LookupName(
id=self.local_ident,
readable_name=util.get_hostname()
)
)
future.add_done_callback(get_info_finished)

# Run in thread pool
def update_remote_machine_avatar(self):
logging.debug("Remote RPC: calling GetRemoteMachineAvatar on '%s'" % self.display_hostname)
iterator = self.stub.GetRemoteMachineAvatar(warp_pb2.LookupName(id=self.local_ident,
readable_name=util.get_hostname()))
iterator = self.stub.GetRemoteMachineAvatar(
warp_pb2.LookupName(
id=self.local_ident,
readable_name=util.get_hostname()
)
)
loader = None
try:
for info in iterator:
Expand All @@ -388,7 +397,7 @@ def update_remote_machine_avatar(self):

self.get_avatar_surface(loader)

@util._idle
@misc._idle
def get_avatar_surface(self, loader=None):
# This needs to be on the main loop, or else we get an x error
if loader:
Expand All @@ -405,17 +414,22 @@ def send_transfer_op_request(self, op):

logging.debug("Remote RPC: calling TransferOpRequest on '%s'" % (self.display_hostname))

transfer_op = warp_pb2.TransferOpRequest(info=warp_pb2.OpInfo(ident=op.sender,
timestamp=op.start_time,
readable_name=util.get_hostname(),
use_compression=prefs.use_compression()),
sender_name=op.sender_name,
receiver=self.ident,
size=op.total_size,
count=op.total_count,
name_if_single=op.description,
mime_if_single=op.mime_if_single,
top_dir_basenames=op.top_dir_basenames)
transfer_op = warp_pb2.TransferOpRequest(
info=warp_pb2.OpInfo(
ident=op.sender,
timestamp=op.start_time,
readable_name=util.get_hostname(),
use_compression=prefs.use_compression(),
),
sender_name=op.sender_name,
receiver=self.ident,
size=op.total_size,
count=op.total_count,
name_if_single=op.description,
mime_if_single=op.mime_if_single,
top_dir_basenames=op.top_dir_basenames
)

self.stub.ProcessTransferOpRequest(transfer_op)

# Run in thread pool
Expand All @@ -426,9 +440,13 @@ def cancel_transfer_op_request(self, op, by_sender=False):
name = op.sender
else:
name = self.local_ident
self.stub.CancelTransferOpRequest(warp_pb2.OpInfo(timestamp=op.start_time,
ident=name,
readable_name=util.get_hostname()))
self.stub.CancelTransferOpRequest(
warp_pb2.OpInfo(
timestamp=op.start_time,
ident=name,
readable_name=util.get_hostname()
)
)
op.set_status(OpStatus.CANCELLED_PERMISSION_BY_SENDER if by_sender else OpStatus.CANCELLED_PERMISSION_BY_RECEIVER)

# Run in thread pool
Expand Down Expand Up @@ -457,6 +475,10 @@ def start_transfer_op(self, op):
def report_receive_error(error):
op.file_iterator = None

# Get rid of any toplevel file/folder if the transfer stops prematurely,
# so it or its children
receiver.clean_current_top_dir_file()

if error is None:
return

Expand All @@ -476,6 +498,8 @@ def report_receive_error(error):
op.stop_transfer()

try:
receiver.clean_existing_files()

for data in op.file_iterator:
receiver.receive_data(data)

Expand All @@ -486,31 +510,25 @@ def report_receive_error(error):
(op.total_count, GLib.format_size(op.total_size),\
util.precise_format_time_span(GLib.get_monotonic_time() - start_time)))

if receiver.remaining_files > 0:
raise ReceiveError(_("Transfer completed, but the number of files received is less than the original request size (expected %d, received %d)"
% (op.total_count, op.total_count - receiver.remaining_files)),
if op.remaining_count > 0:
raise ReceiveError("Transfer completed, but the number of files received is less than the original request size (expected %d, received %d)"
% (op.total_count, op.total_count - receiver.remaining_count),
fatal=False)
op.set_status(OpStatus.FINISHED)
except grpc.RpcError as e:
if e.code() == grpc.StatusCode.CANCELLED:
report_receive_error(None)
return
else:
report_receive_error(e)
return
except ReceiveError as e:
if e.fatal:
report_receive_error(e)
return
else:
logging.critical(str(e))
op.set_error(e)
op.set_status(OpStatus.FINISHED_WARNING)
return
except Exception as e:
report_receive_error(e)
return

op.set_status(OpStatus.FINISHED)

# Run in thread pool
def stop_transfer_op(self, op, by_sender=False, lost_connection=False):
Expand Down Expand Up @@ -544,25 +562,30 @@ def stop_transfer_op(self, op, by_sender=False, lost_connection=False):
if not lost_connection:
# We don't need to send this if it's a connection loss, the other end will handle
# its own cleanup.
opinfo = warp_pb2.OpInfo(timestamp=op.start_time,
ident=name,
readable_name=util.get_hostname())
opinfo = warp_pb2.OpInfo(
timestamp=op.start_time,
ident=name,
readable_name=util.get_hostname()
)
self.stub.StopTransfer(warp_pb2.StopInfo(info=opinfo, error=op.error_msg != ""))

# Op handling (run in thread pool)
def send_files(self, uri_list):
def send_files(self, uri_list, dbus_sent=False):
def _send_files(uri_list):
op = SendOp(self.local_ident,
self.ident,
self.display_name,
uri_list)
op = SendOp(
self.local_ident,
self.ident,
self.display_name,
uri_list
)
op.dbus_op = dbus_sent
self.add_op(op)
op.prepare_send_info()

util.add_to_recents_if_single_selection(uri_list)
self.rpc_call(_send_files, uri_list)

@util._idle
@misc._idle
def add_op(self, op):
if op not in self.transfer_ops:
self.transfer_ops.append(op)
Expand All @@ -581,15 +604,22 @@ def set_busy():
op.connect("active", lambda op: set_busy())

self.emit_ops_changed()

# For now, only bad base filenames cause this (failed util.test_resolved_path_safety())
# We let it get this far so the UI has something to show the user.
if op.status == OpStatus.FAILED_UNRECOVERABLE:
op.decline_transfer_request()
return

self.check_for_autostart(op)

@util._idle
@misc._idle
def notify_remote_machine_of_new_op(self, op):
if op.status == OpStatus.WAITING_PERMISSION:
if op.direction == TransferDirection.TO_REMOTE_MACHINE:
self.rpc_call(self.send_transfer_op_request, op)

@util._idle
@misc._idle
def check_for_autostart(self, op):
if op.status == OpStatus.WAITING_PERMISSION:
if isinstance(op, ReceiveOp) and \
Expand All @@ -602,7 +632,7 @@ def remove_op(self, op):
self.transfer_ops.remove(op)
self.emit_ops_changed()

@util._idle
@misc._idle
def emit_ops_changed(self, op=None):
self.emit("ops-changed")

Expand All @@ -617,7 +647,7 @@ def cancel_ops_if_offline(self):
op.error_msg = _("Connection has been lost")
op.set_status(OpStatus.FAILED_UNRECOVERABLE)

@util._idle
@misc._idle
def op_command_issued(self, op, command):
# send
if command == OpCommand.CANCEL_PERMISSION_BY_SENDER:
Expand All @@ -639,7 +669,7 @@ def op_command_issued(self, op, command):
elif command == OpCommand.STOP_TRANSFER_BY_RECEIVER:
self.rpc_call(self.stop_transfer_op, op, by_sender=False)

@util._idle
@misc._idle
def op_focus(self, op):
self.emit("focus-remote")

Expand Down
41 changes: 32 additions & 9 deletions src/server.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import remote_registration
import prefs
import util
import misc
import transfers
from ops import ReceiveOp
from util import TransferDirection, OpStatus, RemoteStatus
Expand Down Expand Up @@ -94,7 +95,7 @@ def start_zeroconf(self):

self.zeroconf = Zeroconf(interfaces=[self.ip_info.ip4_address])

self.service_ident = auth.get_singleton().get_ident()
self.service_ident = prefs.get_connect_id()
self.service_name = "%s.%s" % (self.service_ident, SERVICE_TYPE)

# If this process is killed (either kill or network issue), the service
Expand Down Expand Up @@ -198,7 +199,7 @@ def add_service(self, zeroconf, _type, name):
try:
machine = self.remote_machines[ident]
machine.has_zc_presence = True
logging.debug(">>> Discovery: existing remote: %s (%s:%d)"
logging.info(">>> Discovery: existing remote: %s (%s:%d)"
% (machine.display_hostname, remote_ip_info.ip4_address, info.port))

# If the remote truly is the same one (our service info just dropped out
Expand Down Expand Up @@ -244,7 +245,7 @@ def add_service(self, zeroconf, _type, name):
if not found:
break

logging.debug(">>> Discovery: new remote: %s (%s:%d)"
logging.info(">>> Discovery: new remote: %s (%s:%d)"
% (display_hostname, remote_ip_info.ip4_address, info.port))

machine = remote.RemoteMachine(ident,
Expand All @@ -257,7 +258,7 @@ def add_service(self, zeroconf, _type, name):

# This blocks the zeroconf thread. Registration will timeout
if not self.remote_registrar.register(ident, remote_hostname, remote_ip_info, info.port, auth_port, api_version) or self.server_thread_keepalive.is_set():
logging.warning("Register failed, or the server was shutting down during registration, ignoring remote %s (%s:%d) auth port: %d"
logging.debug("Register failed, or the server was shutting down during registration, ignoring remote %s (%s:%d) auth port: %d"
% (remote_hostname, remote_ip_info.ip4_address, info.port, auth_port))
return

Expand All @@ -279,7 +280,7 @@ def add_service(self, zeroconf, _type, name):
def run(self):
logging.debug("Server: starting server on %s (%s)" % (self.ip_info.ip4_address, self.ip_info.iface))
logging.info("Using api version %s" % config.RPC_API_VERSION)
logging.info("Our uuid: %s" % auth.get_singleton().get_ident())
logging.info("Our uuid: %s" % prefs.get_connect_id())


self.remote_registrar = remote_registration.Registrar(self.ip_info, self.port, self.auth_port)
Expand Down Expand Up @@ -369,14 +370,36 @@ def remote_status_changed(self, remote):
def add_receive_op_to_remote_machine(self, op):
self.remote_machines[op.sender].add_op(op)

@util._idle
@misc._idle
def remote_ops_changed(self, remote_machine):
self.emit("remote-machine-ops-changed", remote_machine.ident)

def list_remote_machines(self):
return self.remote_machines.values()

@util._idle
def get_active_op_count(self, incoming_only=False):
count = 0

for machine in self.remote_machines.values():
if machine.status != RemoteStatus.ONLINE:
continue
for op in machine.transfer_ops:
if incoming_only and not isinstance(op, ReceiveOp):
continue
if op.status == OpStatus.TRANSFERRING:
count += 1

return count

def cancel_all_ops(self):
for machine in self.remote_machines.values():
if machine.status != RemoteStatus.ONLINE:
continue
for op in machine.transfer_ops:
if op.status == OpStatus.TRANSFERRING:
op.stop_transfer()

@misc._idle
def idle_emit(self, signal, *callback_data):
self.emit(signal, *callback_data)

Expand Down Expand Up @@ -453,7 +476,7 @@ def ProcessTransferOpRequest(self, request, context):
try:
remote_machine = self.remote_machines[request.info.ident]
except KeyError as e:
logging.warning("Received transfer op request for unknown op: %s" % e)
logging.warning("Received transfer op request for unknown remote: %s" % e)
return

for existing_op in remote_machine.transfer_ops:
Expand All @@ -476,7 +499,7 @@ def ProcessTransferOpRequest(self, request, context):
op.receiver_name = request.receiver_name
op.status = OpStatus.WAITING_PERMISSION
op.total_size = request.size
op.total_count = request.count
op.total_count = op.remaining_count = request.count
op.mime_if_single = request.mime_if_single
op.name_if_single = request.name_if_single
op.top_dir_basenames = request.top_dir_basenames
Expand Down
75 changes: 44 additions & 31 deletions src/transfers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import util
from util import FileType, ReceiveError
import prefs
import misc
import warp_pb2

_ = gettext.gettext
Expand Down Expand Up @@ -168,7 +169,6 @@ class FileReceiver(GObject.Object):
def __init__(self, op):
super(FileReceiver, self).__init__()
self.save_path = prefs.get_save_path()
self.save_path_obj = Path(self.save_path).resolve()
self.op = op
self.preserve_perms = prefs.preserve_permissions() and util.save_folder_is_native_fs()
self.preserve_timestamp = prefs.preserve_timestamp() and util.save_folder_is_native_fs()
Expand All @@ -181,48 +181,61 @@ def __init__(self, op):
self.current_mtime = 0
self.current_mtime_usec = 0

self.remaining_files = op.total_count
self.remaining_bytes = op.total_size

for name in op.top_dir_basenames:
try:
path = os.path.join(self.save_path, name)
if os.path.isdir(path): # file not found is ok
shutil.rmtree(path)
else:
os.remove(path)
except FileNotFoundError:
pass
except Exception as e:
logging.warning("Problem removing existing files. Transfer may not succeed: %s" % e)

# We write files top-down. If we're preserving permissions and we receive
# a folder in some hierarchy that is not writable, we won't be able to create
# anything inside it.
self.folder_permission_change_list = []

def receive_data(self, s):
save_path = prefs.get_save_path()
def clean_existing_files(self):
logging.debug("Removing any existing files matching the pending transfer")
for name in self.op.top_dir_basenames:
path = Path(os.path.join(self.save_path, name))
self.rm_any(path)

def clean_current_top_dir_file(self):
if self.current_path is not None:
current = Path(self.current_path)
save = Path(self.save_path)

try:
relative = current.relative_to(save)
util.test_resolved_path_safety(relative.as_posix())
except (ValueError, ReceiveError) as e:
logging.critical("Partial file or directory from aborted transfer is invalid: %s" % str(e))
return

path = os.path.join(save_path, s.relative_path)
abs_top_dir = save.joinpath(relative.parts[0])
logging.debug("Removing partial file or directory: %s" % abs_top_dir)

self.rm_any(abs_top_dir)

def rm_any(self, path):
try:
try:
os.remove(path)
except IsADirectoryError:
shutil.rmtree(path)
except FileNotFoundError:
pass
except Exception as e:
logging.warning("Problem removing existing files: %s" % e)

def receive_data(self, s):
path = os.path.join(self.save_path, s.relative_path)
if path != self.current_path:
self.close_current_file()
self.current_path = path
self.current_mode = s.file_mode
self.current_type = s.file_type
self.current_mtime = s.time.mtime
self.current_mtime_usec = s.time.mtime_usec
if self.remaining_files == 0:
raise Exception(_("File count exceeds original request size"))
if not s.relative_path.startswith(tuple(self.op.top_dir_basenames)):
raise ReceiveError("File path is not descended from a valid toplevel directory: %s" % s.relative_path)
if self.op.remaining_count == 0:
raise ReceiveError("File count exceeds original request size")

if not self.current_gfile:
# Check for valid path (pathlib.Path resolves both relative and symbolically-linked paths)
test_path = Path(path).resolve()
try:
test_path.relative_to(self.save_path_obj)
except ValueError:
raise ReceiveError(_("Resolved path is not valid: %s -> %s") % (path, str(test_path)), fatal=True)

util.test_resolved_path_safety(s.relative_path)
self.current_gfile = Gio.File.new_for_path(path)

if s.file_type == FileType.DIRECTORY:
Expand Down Expand Up @@ -280,7 +293,7 @@ def close_current_file(self):
self.current_mode = 0
self.current_path = None
self.current_gfile = None
self.remaining_files -= 1
self.op.remaining_count -= 1

def apply_folder_permissions(self):
if self.preserve_perms:
Expand Down Expand Up @@ -379,7 +392,7 @@ def process_folder(folder_uri, top_dir):
info = file.query_info(infos, Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, None)
basename = file.get_basename()
if len(uri_list) == 1:
op.mime_if_single = info.get_content_type()
op.mime_if_single = info.get_attribute_string(Gio.FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE)

if info and info.get_file_type() == FileType.DIRECTORY:
top_dir = file.get_parent().get_uri()
Expand Down Expand Up @@ -409,7 +422,7 @@ def __init__(self, op):
self.transfer_start_time = GLib.get_monotonic_time()
self.last_update_time = self.transfer_start_time

@util._idle
@misc._idle
def update_progress(self, size_read):
self.total_transferred += size_read

Expand Down
Loading