From c3281715276277a9e5f10989f69a1de78c1b5bd6 Mon Sep 17 00:00:00 2001 From: Marko Semet Date: Thu, 16 Apr 2020 00:00:37 +0200 Subject: [PATCH] Base sys service. --- .gitignore | 2 +- README.md | 6 ++++ home_backup/__main__.py | 31 ++++++++++++++++++ home_backup/defaults.py | 1 + home_backup/sys_service/btrfs.py | 53 +++++++++++++++++++++++++++++++ home_backup/sys_service/mounts.py | 46 +++++++++++++++++++++++++++ home_backup/sys_service/rpc.py | 19 +++++++++++ home_backup/sys_service/utils.py | 48 ++++++++++++++++++++++++++++ setup.py | 17 ++++++++++ venv.sh | 14 ++++++++ 10 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 home_backup/__main__.py create mode 100644 home_backup/defaults.py create mode 100644 home_backup/sys_service/btrfs.py create mode 100644 home_backup/sys_service/mounts.py create mode 100644 home_backup/sys_service/rpc.py create mode 100644 home_backup/sys_service/utils.py create mode 100644 setup.py create mode 100755 venv.sh diff --git a/.gitignore b/.gitignore index 13d1490..a812220 100644 --- a/.gitignore +++ b/.gitignore @@ -128,4 +128,4 @@ dmypy.json # Pyre type checker .pyre/ - +/venv \ No newline at end of file diff --git a/README.md b/README.md index 20160ae..4b44df9 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,8 @@ # home-backup +# Todos + +* Protocol documentation + * JSON base protocol + * Callbacks and result data structure +* Change system service socket path \ No newline at end of file diff --git a/home_backup/__main__.py b/home_backup/__main__.py new file mode 100644 index 0000000..0c3dccb --- /dev/null +++ b/home_backup/__main__.py @@ -0,0 +1,31 @@ +import argparse +import asyncio +import os +from .sys_service import rpc + + +def main(args): + # Parser + parser = argparse.ArgumentParser("home-backup", description="Manage backup tools") + parser.add_argument("--sys-server", dest="sys_server", action="store_const", const=True, default=False, help="Run system service (root required).") + + # Arguments + if not args: + args = ["--help"] + result = parser.parse_args(args) + + # Deamon mode + if not isinstance(result.sys_server, bool): + raise RuntimeError("Arg parser has the wrong type.") + if result.sys_server: + # Check if root + if os.getuid() != 0: + raise RuntimeError("System service has to run as root.") + + # Run deamon + asyncio.run(rpc.run_deamon()) # TODO: Change default path + + +if __name__ == "__main__": + import sys + main(sys.argv[1:]) \ No newline at end of file diff --git a/home_backup/defaults.py b/home_backup/defaults.py new file mode 100644 index 0000000..8ccd691 --- /dev/null +++ b/home_backup/defaults.py @@ -0,0 +1 @@ +DEFAULT_PATH = "/run/home_backup.socket" \ No newline at end of file diff --git a/home_backup/sys_service/btrfs.py b/home_backup/sys_service/btrfs.py new file mode 100644 index 0000000..0d96977 --- /dev/null +++ b/home_backup/sys_service/btrfs.py @@ -0,0 +1,53 @@ +import asyncio +import os +from . import mounts + + +async def list_subvolumes_of(path:str): + # Run process + if isinstance(path, str): + raise ValueError("path isn't a string.") + proc = await asyncio.create_subprocess_exec([b"btrfs", b"subvolume", b"list", b"-o", path.encode()], stdout=asyncio.subprocess.PIPE) + proc_data = await proc.communicate() + if not isinstance(proc_data, tuple): + raise RuntimeError("Type doesn't match.") + if proc.returncode != 0: + raise RuntimeError("btrfs coudn't list subevolumes.") + + # Generate list + result = [] + for i in proc_data[0].splitlines(): + if i: # Remove empty line + tmp = i.split(" ")[7:] + if tmp[0] != "path": + raise RuntimeError("Btrfs progs doesn't output as expected.") + result.append(" ".join(tmp[1:])) + return result + + +async def list_path_subvolumes(mount:mounts.Mountpoint, path:str): + # Check types + if not isinstance(mount, mounts.Mountpoint): + raise TypeError("mount has to be a Mountpoint.") + if not isinstance(path, str): + raise TypeError("path has to be a string.") + if mount.target == "btrfs": + raise ValueError("mount type isn't a btrfs file system.") + if not isinstance(mount.subvolume, str): + raise ValueError("mount doesn't have a subvolume.") + + # Generate list + raw_list = await list_subvolumes_of(path) + path = os.path.abspath(path) + if not path.startswith(mount.target): + raise ValueError("path isn't inside of the mounting point.") + own_path = "/".join(filter(bool, ("%s/%s" % (mount.subvolume, path[len(mount.target):])).split("/"))) + result = [] + for i in map(lambda x: "/".join(filter(bool, x.split("/"))), raw_list): + if not i.startswith(own_path): + raise ValueError("%s isn't a subpath of a the main path %s." % (repr(i), repr(own_path))) + tmp = i[len(own_path):] + if tmp and tmp[0] == "/": + tmp = tmp[1:] + result.append(tmp) + return result \ No newline at end of file diff --git a/home_backup/sys_service/mounts.py b/home_backup/sys_service/mounts.py new file mode 100644 index 0000000..4079f1d --- /dev/null +++ b/home_backup/sys_service/mounts.py @@ -0,0 +1,46 @@ +import asyncio +import json + + +class Mountpoint(): + fstype:str + target:str + subvolume:str + + def __init__(self, fstype:str, target:str, subvolume:str): + # Check args + if not isinstance(fstype, str): + raise ValueError("fstype isn't a strng.") + if not isinstance(target, str): + raise ValueError("target isn't a strng.") + if subvolume is not None and not isinstance(subvolume, str): + raise ValueError("subvolume isn't a strng.") + + # Set values + self.fstype = fstype + self.target = target + self.subvolume = subvolume + + +async def list_mounts(): + # List mounts + proc_call = await asyncio.create_subprocess_exec(b"findmnt", b"-lJ", stdout=asyncio.subprocess.PIPE) + proc_data = await proc_call.communicate() + if not isinstance(proc_data, tuple): + raise RuntimeError("Type doesn't match.") + if proc_call.returncode != 0: + raise RuntimeError("Can't find mounts.") + data = json.loads(proc_data[0]) + + # Parse mounts + result = [] + for mount in data["filesystem"]: + subvolume = None + if mount["fstype"] == "btrfs": + search_term = "subvol=" + tmp = list(filter(lambda x: x.startswith(search_term), mount["options"].split(","))) + if len(tmp) != 1: + raise ValueError("Can't find subvolume of mount %s." % repr(mount["target"])) + subvolume = tmp[0][len(search_term):] + result.append(Mountpoint(mount["target"], mount["fstype"], subvolume)) + return result \ No newline at end of file diff --git a/home_backup/sys_service/rpc.py b/home_backup/sys_service/rpc.py new file mode 100644 index 0000000..166cb8b --- /dev/null +++ b/home_backup/sys_service/rpc.py @@ -0,0 +1,19 @@ +from . import btrfs, mounts, utils +from .. import defaults + + +@utils.rpc_callback +async def callback_func(data, uid): + # Get operation + if not isinstance(data, dict): + raise ValueError("data have to be a object.") + if "operation" not in data: + raise ValueError("'operation' isn't set.") + operation = data["operation"] + + # Run operation + raise NotImplementedError() + + +async def run_deamon(path:str=defaults.DEFAULT_PATH): + await utils.run_access_socket(path, callback_func) \ No newline at end of file diff --git a/home_backup/sys_service/utils.py b/home_backup/sys_service/utils.py new file mode 100644 index 0000000..283a3cc --- /dev/null +++ b/home_backup/sys_service/utils.py @@ -0,0 +1,48 @@ +import asyncio +import functools +import json +import struct + + +async def get_user_home(name:str): + # Check args + if not isinstance(name, str): + raise TypeError("name have to be a string.") + + # Find home + proc = await asyncio.create_subprocess_exec(b"getent", b"passwd", name.encode(), stdout=asyncio.subprocess.PIPE) + proc_data = proc.communicate() + if not isinstance(proc_data, tuple): + raise RuntimeError("Internal value isn't the expected type.") + if proc.returncode != 0: + raise RuntimeError("getent didn't work.") + return proc_data[0].split(":")[6] + + +async def run_access_socket(path:str, async_callback): + async def run_func(read, write): + # Get user id + socket = write.get_extra_info("socket") + tmp = socket.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize('3i')) + tmp = struct.unpack('3i', tmp)[1] + uid = str(tmp) + + # Run callback + await async_callback(read, write, uid) + await (await asyncio.start_unix_server(run_func, path=path)).serve_forever() + + +_format_length = struct.Struct(">I") +def rpc_callback(async_func): + @functools.wraps(async_func) + async def wrap_func(read, write, uid): + while not read.at_eof(): + # Read data + size = _format_length.unpack(await read.readexactly(_format_length.size)) + data = json.loads((await read.readexactly(size)).decode("UTF-8")) + + # Callback and return result + result = json.dumps(await async_func(data, uid)).encode("UTF-8") + write.write(_format_length.pack(len(result))) + write.write(result) + return wrap_func \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..03a7599 --- /dev/null +++ b/setup.py @@ -0,0 +1,17 @@ +from setuptools import setup, find_packages + + +# Config +VERSION = "0.1" + +# Setup +setup(name="home-backup", + version=VERSION, + license="AGPLv3+", + description="Automation to backup home filsystem (using btrfs features).", + author="Marko Semet", + author_email="marko@marko10-000.de", + #url="", TODO: Make a project site + packages=find_packages(), + install_requires=["systemd==0.16.1"] +) \ No newline at end of file diff --git a/venv.sh b/venv.sh new file mode 100755 index 0000000..a05f97d --- /dev/null +++ b/venv.sh @@ -0,0 +1,14 @@ +#! /usr/bin/bash + +cd "$(dirname "$0")" +python3 -m venv venv && +source venv/bin/activate && +pip install --upgrade pip && +pip install . && +if [ "$#" -gt 1 ] +then + exec -- "$@" +else + echo Using shell "$SHELL" + exec "$SHELL" +fi \ No newline at end of file