mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
319 lines
11 KiB
Python
319 lines
11 KiB
Python
#!/usr/bin/python3
|
|
# bubblewrap for Flatpak (FLATPAK_BWRAP), in a runtime that cannot have it.
|
|
#
|
|
# bwrap builds each sandbox from mount namespaces, which Android gives no app. proot gives the same
|
|
# picture - Flatpak's runtime as /usr, the app as /app, host paths bound where Flatpak asks - but a
|
|
# proot nested inside the session's proot runs every system call through two tracers, some thirty
|
|
# times slower on a program's start. So a sandbox is described to the app instead, over a socket
|
|
# only this app's uid may use, and the app starts it as a proot of its own beside the session's
|
|
# (BwrapSpawner). This process stands in for the sandbox until it ends: its output, its pid for
|
|
# --info-fd, and its exit status come back here.
|
|
#
|
|
# Sandboxes that remap nothing - Flatpak's install triggers (`--ro-bind / /` and the directory they
|
|
# update) and the D-Bus proxy (every top-level directory bound onto itself) - need no proot at all
|
|
# and run here directly, with the file descriptors Flatpak handed them.
|
|
import base64
|
|
import json
|
|
import os
|
|
import signal
|
|
import socket
|
|
import struct
|
|
import sys
|
|
|
|
SOCKET = "\0" + os.environ.get("DROIDDECK_BWRAP_SOCKET", "com.droiddeck.launcher.bwrap")
|
|
VERSION = "bubblewrap 0.11.0 (droiddeck)"
|
|
|
|
# Options and how many arguments each takes; bwrap(1).
|
|
NO_ARG = {
|
|
"--unshare-user", "--unshare-user-try", "--unshare-ipc", "--unshare-pid", "--unshare-net",
|
|
"--unshare-uts", "--unshare-cgroup", "--unshare-cgroup-try", "--unshare-all", "--share-net",
|
|
"--disable-userns", "--assert-userns-disabled", "--clearenv", "--new-session",
|
|
"--die-with-parent", "--as-pid-1", "--level-prefix", "--tmp-overlay", "--ro-overlay",
|
|
}
|
|
ONE_ARG = {
|
|
"--args", "--argv0", "--userns", "--userns2", "--pidns", "--uid", "--gid", "--hostname",
|
|
"--chdir", "--unsetenv", "--lock-file", "--sync-fd", "--remount-ro", "--exec-label",
|
|
"--file-label", "--proc", "--dev", "--tmpfs", "--mqueue", "--dir", "--seccomp",
|
|
"--add-seccomp-fd", "--block-fd", "--userns-block-fd", "--info-fd", "--json-status-fd",
|
|
"--cap-add", "--cap-drop", "--perms", "--size", "--overlay-src",
|
|
}
|
|
TWO_ARG = {
|
|
"--setenv", "--bind", "--bind-try", "--dev-bind", "--dev-bind-try", "--ro-bind",
|
|
"--ro-bind-try", "--bind-fd", "--ro-bind-fd", "--file", "--bind-data", "--ro-bind-data",
|
|
"--symlink", "--chmod",
|
|
}
|
|
THREE_ARG = {"--overlay"}
|
|
|
|
|
|
def read_fd(fd):
|
|
chunks = []
|
|
while True:
|
|
b = os.read(fd, 65536)
|
|
if not b:
|
|
break
|
|
chunks.append(b)
|
|
os.close(fd)
|
|
return b"".join(chunks)
|
|
|
|
|
|
def fd_path(fd):
|
|
return os.readlink("/proc/self/fd/%d" % fd)
|
|
|
|
|
|
def expand(argv):
|
|
"""argv with every --args FD replaced by the NUL-separated arguments read from FD."""
|
|
out = []
|
|
i = 0
|
|
while i < len(argv):
|
|
a = argv[i]
|
|
if a == "--args" and i + 1 < len(argv):
|
|
data = read_fd(int(argv[i + 1]))
|
|
# Each argument ends in a NUL; an empty one (--setenv LD_LIBRARY_PATH "") is kept.
|
|
parts = data.split(b"\0")
|
|
if parts and parts[-1] == b"":
|
|
parts.pop()
|
|
out.extend(x.decode("utf-8", "surrogateescape") for x in parts)
|
|
i += 2
|
|
continue
|
|
out.append(a)
|
|
i += 1
|
|
return out
|
|
|
|
|
|
class Spec:
|
|
def __init__(self):
|
|
self.ops = []
|
|
self.env = dict(os.environ)
|
|
self.cwd = None
|
|
self.info_fds = []
|
|
self.json_fds = []
|
|
self.keep_fds = []
|
|
self.command = []
|
|
self.argv0 = None
|
|
|
|
|
|
def parse(argv):
|
|
s = Spec()
|
|
perms = None
|
|
i = 0
|
|
while i < len(argv):
|
|
a = argv[i]
|
|
if a == "--":
|
|
s.command = argv[i + 1:]
|
|
break
|
|
if not a.startswith("--"):
|
|
s.command = argv[i:]
|
|
break
|
|
if a in ("--version",):
|
|
print(VERSION)
|
|
sys.exit(0)
|
|
if a == "--help":
|
|
print("usage: bwrap [OPTIONS...] [--] COMMAND [ARGS...]")
|
|
sys.exit(0)
|
|
n = 0 if a in NO_ARG else 1 if a in ONE_ARG else 2 if a in TWO_ARG else 3 if a in THREE_ARG else -1
|
|
if n < 0:
|
|
sys.stderr.write("bwrap: Unknown option %s\n" % a)
|
|
sys.exit(1)
|
|
args = argv[i + 1:i + 1 + n]
|
|
if len(args) < n:
|
|
sys.stderr.write("bwrap: %s takes %d arguments\n" % (a, n))
|
|
sys.exit(1)
|
|
i += 1 + n
|
|
op_perms, perms = perms, None
|
|
if a == "--perms":
|
|
perms = int(args[0], 8)
|
|
elif a == "--clearenv":
|
|
s.env = {"PWD": os.getcwd()} if "PWD" in os.environ else {}
|
|
elif a == "--setenv":
|
|
s.env[args[0]] = args[1]
|
|
elif a == "--unsetenv":
|
|
s.env.pop(args[0], None)
|
|
elif a == "--chdir":
|
|
s.cwd = args[0]
|
|
elif a == "--argv0":
|
|
s.argv0 = args[0]
|
|
elif a in ("--bind", "--bind-try", "--dev-bind", "--dev-bind-try", "--ro-bind", "--ro-bind-try"):
|
|
s.ops.append({"t": "bind", "src": args[0], "dest": args[1], "try": a.endswith("-try"),
|
|
"ro": a.startswith("--ro")})
|
|
elif a in ("--bind-fd", "--ro-bind-fd"):
|
|
fd = int(args[0])
|
|
s.ops.append({"t": "bind", "src": fd_path(fd), "dest": args[1], "try": False,
|
|
"ro": a.startswith("--ro")})
|
|
os.close(fd)
|
|
elif a in ("--file", "--bind-data", "--ro-bind-data"):
|
|
s.ops.append({"t": "file", "dest": args[1], "data": read_fd(int(args[0])),
|
|
"perms": op_perms if op_perms is not None else 0o666 if a == "--file" else 0o600})
|
|
elif a == "--symlink":
|
|
s.ops.append({"t": "symlink", "target": args[0], "dest": args[1]})
|
|
elif a == "--dir":
|
|
s.ops.append({"t": "dir", "dest": args[0], "perms": op_perms if op_perms is not None else 0o755})
|
|
elif a in ("--tmpfs", "--mqueue"):
|
|
s.ops.append({"t": "dir", "dest": args[0], "perms": op_perms if op_perms is not None else 0o755,
|
|
"empty": True})
|
|
elif a == "--proc":
|
|
s.ops.append({"t": "proc", "dest": args[0]})
|
|
elif a == "--dev":
|
|
s.ops.append({"t": "dev", "dest": args[0]})
|
|
elif a in ("--seccomp", "--add-seccomp-fd"):
|
|
# proot is the sandbox; there is no filter to install under it.
|
|
os.close(int(args[0]))
|
|
elif a == "--block-fd":
|
|
read_fd(int(args[0]))
|
|
elif a == "--sync-fd":
|
|
s.keep_fds.append(int(args[0]))
|
|
elif a == "--info-fd":
|
|
s.info_fds.append(int(args[0]))
|
|
elif a == "--json-status-fd":
|
|
s.json_fds.append(int(args[0]))
|
|
# Everything else - namespaces, capabilities, labels, locks, --remount-ro, --chmod - has
|
|
# nothing to act on under proot.
|
|
if not s.command:
|
|
sys.stderr.write("bwrap: No command specified\n")
|
|
sys.exit(1)
|
|
return s
|
|
|
|
|
|
def real(path):
|
|
try:
|
|
return os.path.realpath(path)
|
|
except OSError:
|
|
return path
|
|
|
|
|
|
def remaps_nothing(s):
|
|
"""True when every path in the sandbox is what it is out here already."""
|
|
for op in s.ops:
|
|
t = op["t"]
|
|
if t == "bind":
|
|
src = real(op["src"])
|
|
if not os.path.exists(src):
|
|
if op["try"]:
|
|
continue
|
|
return False
|
|
if src != real(op["dest"]) and not (op["src"] == "/" and op["dest"] == "/"):
|
|
return False
|
|
elif t == "symlink":
|
|
if not os.path.islink(op["dest"]) or os.readlink(op["dest"]) != op["target"]:
|
|
return False
|
|
elif t == "file":
|
|
# The D-Bus proxy's /.flatpak-info names the app to the bus; nothing here reads it.
|
|
if op["dest"] != "/.flatpak-info":
|
|
return False
|
|
elif t == "dir":
|
|
if not os.path.isdir(op["dest"]) or op.get("empty") and op["dest"] not in ("/tmp", "/var/tmp", "/run"):
|
|
return False
|
|
elif t in ("proc", "dev"):
|
|
if op["dest"] != "/" + t:
|
|
return False
|
|
return True
|
|
|
|
|
|
def report_pid(s, pid):
|
|
for fd in s.info_fds:
|
|
try:
|
|
os.write(fd, json.dumps({"child-pid": pid}).encode() + b"\n")
|
|
os.close(fd)
|
|
except OSError:
|
|
pass
|
|
for fd in s.json_fds:
|
|
try:
|
|
os.write(fd, json.dumps({"child-pid": pid}).encode() + b"\n")
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def run_here(s):
|
|
report_pid(s, os.getpid())
|
|
for fd in s.json_fds:
|
|
os.close(fd)
|
|
if s.cwd:
|
|
os.chdir(s.cwd)
|
|
argv = list(s.command)
|
|
program = argv[0]
|
|
if s.argv0:
|
|
argv[0] = s.argv0
|
|
try:
|
|
os.execvpe(program, argv, s.env)
|
|
except OSError as e:
|
|
sys.stderr.write("bwrap: execvp %s: %s\n" % (program, e.strerror))
|
|
sys.exit(1)
|
|
|
|
|
|
def send(sock, kind, payload):
|
|
sock.sendall(kind + struct.pack(">I", len(payload)) + payload)
|
|
|
|
|
|
def recv_exact(sock, n):
|
|
buf = b""
|
|
while len(buf) < n:
|
|
b = sock.recv(n - len(buf))
|
|
if not b:
|
|
return None
|
|
buf += b
|
|
return buf
|
|
|
|
|
|
def run_by_app(s):
|
|
ops = []
|
|
for op in s.ops:
|
|
op = dict(op)
|
|
if op["t"] == "bind":
|
|
# The app resolves no guest symlinks; hand it the path they lead to.
|
|
op["src"] = real(op["src"])
|
|
if op["t"] == "file":
|
|
op["data"] = base64.b64encode(op["data"]).decode()
|
|
ops.append(op)
|
|
argv = list(s.command)
|
|
if s.argv0:
|
|
argv[0] = s.argv0
|
|
request = {"argv": argv, "env": s.env, "cwd": s.cwd or "/", "ops": ops}
|
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
try:
|
|
sock.connect(SOCKET)
|
|
except OSError as e:
|
|
sys.stderr.write("bwrap: DroidDeck is not answering (%s); open DroidDeck and try again\n" % e)
|
|
sys.exit(1)
|
|
send(sock, b"R", json.dumps(request).encode())
|
|
# This process is the sandbox as far as Flatpak is concerned: ended, it takes the sandbox down.
|
|
for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
|
|
signal.signal(sig, lambda *_: os._exit(128 + 15))
|
|
status = 1
|
|
out = sys.stdout.buffer
|
|
while True:
|
|
head = recv_exact(sock, 5)
|
|
if head is None:
|
|
sys.stderr.write("bwrap: DroidDeck closed the sandbox\n")
|
|
break
|
|
kind, size = head[:1], struct.unpack(">I", head[1:])[0]
|
|
payload = recv_exact(sock, size) if size else b""
|
|
if payload is None:
|
|
break
|
|
if kind == b"P":
|
|
report_pid(s, int(payload))
|
|
elif kind == b"O":
|
|
out.write(payload)
|
|
out.flush()
|
|
elif kind == b"E":
|
|
sys.stderr.write("bwrap: %s\n" % payload.decode("utf-8", "replace"))
|
|
elif kind == b"X":
|
|
status = int(payload)
|
|
break
|
|
for fd in s.json_fds:
|
|
try:
|
|
os.write(fd, json.dumps({"exit-code": status}).encode() + b"\n")
|
|
except OSError:
|
|
pass
|
|
sys.exit(status)
|
|
|
|
|
|
def main():
|
|
s = parse(expand(sys.argv[1:]))
|
|
if remaps_nothing(s):
|
|
run_here(s)
|
|
else:
|
|
run_by_app(s)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|