🗝
summary refs log tree commit diff
diff options
context:
space:
mode:
authormia <mia@mia.jetzt>2024-06-08 22:55:38 -0700
committermia <mia@mia.jetzt>2024-06-08 22:55:38 -0700
commit3951bfb7c042ada824a66b420703b7587de48e5c (patch)
tree188299df2f35aa0ffe1e7808101e3aff491dbcc0
downloadcommia-3951bfb7c042ada824a66b420703b7587de48e5c.tar.gz
commia-3951bfb7c042ada824a66b420703b7587de48e5c.zip
initial commit
-rw-r--r--.envrc2
-rw-r--r--.gitignore1
-rw-r--r--commia/__init__.py0
-rw-r--r--commia/bearer.py99
-rw-r--r--commia/exit.py25
-rw-r--r--commia/prelude.py31
-rw-r--r--commia/ssh.py122
-rw-r--r--commia/util.py77
8 files changed, 357 insertions, 0 deletions
diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..2b9b391
--- /dev/null
+++ b/.envrc
@@ -0,0 +1,2 @@
+# shellcheck shell=sh
+export PYTHONPATH="$PYTHONPATH:$(pwd)"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ba0430d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+__pycache__/
\ No newline at end of file
diff --git a/commia/__init__.py b/commia/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/commia/__init__.py
diff --git a/commia/bearer.py b/commia/bearer.py
new file mode 100644
index 0000000..6202126
--- /dev/null
+++ b/commia/bearer.py
@@ -0,0 +1,99 @@
+import base64
+
+import pyrage
+
+from commia.prelude import *
+from commia.ssh import sftp_exists, sftp_rm, ssh_opt_args
+
+_target = "secrets@bearer"
+_opts = lambda: ssh_opt_args(_target)
+
+
+@overload
+def get_key(key: "Key", decode: bool = False) -> bytes: ...
+def get_key(key: "Key", decode: bool = True) -> str:
+    found = None
+    for recipient in key.recipients:
+        idn = _identity(recipient)
+        if idn != None:
+            found = idn
+            break
+    else:
+        raise KeyError("no identities found that can decrypt this key")
+    try:
+        with NamedTemporaryFile(mode="rb") as tmp:
+            run_sc(["scp", *_opts(), f"{_target}:data/{key.path}", tmp.name])
+            data = tmp.read()
+        decrypted = pyrage.decrypt(data, [found])
+        if decode:
+            return decrypted.decode()
+        return decrypted
+    except FileNotFoundError:
+        raise KeyError(f"bearer key '{key}' not found")
+
+
+def set_key(key: "Key", value: str | bytes):
+    keys = []
+    for recipient in key.recipients:
+        rec = _recipient(recipient)
+        if rec == None:
+            raise KeyError(f"key recipient '{recipient}' not found")
+        keys.append(rec)
+    if type(value) == str:
+        value = value.encode()
+    encrypted = pyrage.encrypt(value, keys)
+    with NamedTemporaryFile(mode="wb") as tmp:
+        tmp.write(encrypted)
+        tmp.flush()
+        run_sc(["scp", *_opts(), tmp.name, f"{_target}:data/{key.path}"])
+
+
+def del_key(key: "Key"):
+    sftp_rm(f"data/{key.path}", target=_target)
+
+
+def has_key(key: "Key") -> bool:
+    return sftp_exists(f"data/{key.path}", target=_target)
+
+
+_identity_c = {}
+
+
+def _identity(name: str) -> Optional["pyrage.Identity"]:
+    if name in _identity_c:
+        return _identity_c[name]
+    try:
+        data = (Path.home() / f".keys/bearer/{name}.age").read_text()
+        idn = pyrage.x25519.Identity.from_str(data.strip())
+    except FileNotFoundError:
+        idn = None
+    _identity_c[name] = idn
+    return idn
+
+
+def _recipient(name: str) -> Optional["pyrage.Recipient"]:
+    match name:
+        case "fw":
+            return pyrage.x25519.Recipient.from_str(
+                "age17rym2rm0q9ulh75chy7kdzwxvadpthjlj4mhncgguhshddhldglq769xg3"
+            )
+        case _:
+            return None
+
+
+class Key:
+    def __init__(self, path: str, *recipients: str):
+        self.path = path
+        self.recipients = recipients
+
+
+class keys:
+    test = Key("test", "fw")
+
+    class certificates:
+        certs = Key("certificates:certs", "fw")
+        state = Key("certificates:state", "fw")
+        state_modtime = Key("certificates:state-modtime", "fw")
+
+    class searxng:
+        secret_key = Key("searxng:secret-key", "fw")
diff --git a/commia/exit.py b/commia/exit.py
new file mode 100644
index 0000000..59e72c0
--- /dev/null
+++ b/commia/exit.py
@@ -0,0 +1,25 @@
+import atexit
+import signal
+
+from commia.prelude import *
+
+_tasks = []
+_sigint = signal.getsignal(signal.SIGINT)
+
+
+def register_exit(func):
+    _tasks.append(func)
+
+
+def _handler(signum=None, _=None):
+    while len(_tasks) > 0:
+        try:
+            _tasks.pop()()
+        except Exception as e:
+            print("[asylum/exit] error running exit task:", e)
+    if signum != None:
+        _sigint(signum, _)
+
+
+signal.signal(signal.SIGINT, _handler)
+atexit.register(_handler)
diff --git a/commia/prelude.py b/commia/prelude.py
new file mode 100644
index 0000000..88bd88b
--- /dev/null
+++ b/commia/prelude.py
@@ -0,0 +1,31 @@
+import shlex
+from dataclasses import dataclass
+from pathlib import Path
+from subprocess import run
+from tempfile import NamedTemporaryFile, TemporaryDirectory
+from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union, overload
+
+
+def p(cmd: List[str]) -> List[str]:
+    print("[$]", shlex.join(cmd))
+    return cmd
+
+
+def run_silent(*args, **kwargs):
+    kwargs["capture_output"] = True
+    return run(*args, **kwargs)
+
+
+def run_check(*args, **kwargs):
+    kwargs["check"] = True
+    return run(*args, **kwargs)
+
+
+def run_sc(*args, **kwargs):
+    kwargs["capture_output"] = True
+    kwargs["check"] = True
+    return run(*args, **kwargs)
+
+
+def apply(items, func):
+    return list(map(func, items))
diff --git a/commia/ssh.py b/commia/ssh.py
new file mode 100644
index 0000000..fc5a9da
--- /dev/null
+++ b/commia/ssh.py
@@ -0,0 +1,122 @@
+import os
+import signal
+import subprocess
+from threading import Thread
+
+from commia.exit import register_exit
+from commia.prelude import *
+
+_ssh_control: Dict[str, Tuple[subprocess.Popen, str]] = {}
+_temp_dir: Optional[TemporaryDirectory] = None
+
+
+def _terminate():
+    for target, (proc, sock) in _ssh_control.items():
+        if os.path.exists(sock):
+            run_check(["ssh", "-S", sock, "-O", "exit", target])
+        else:
+            proc.send_signal(signal.SIGINT)
+    for proc, _ in _ssh_control.values():
+        try:
+            proc.wait(3)
+        except TimeoutError:
+            proc.terminate()
+    if _temp_dir is not None:
+        _temp_dir.cleanup()
+
+
+register_exit(_terminate)
+
+
+def _ensure_ssh_control(target: str = "asylum") -> str:
+    if target in _ssh_control:
+        return _ssh_control[target][1]
+    global _temp_dir
+    if _temp_dir is None:
+        _temp_dir = TemporaryDirectory()
+    print(f"[*] connecting to ssh://{target}")
+    sock = f"{_temp_dir.name}/{target}.sock"
+    process = subprocess.Popen(
+        [
+            "ssh",
+            "-M",  # master mode
+            "-S",  # control socket
+            sock,
+            "-N",  # don't execute anything
+            target,
+        ]
+    )
+    _ssh_control[target] = (process, sock)
+    return sock
+
+
+def ssh_prewarm(*targets: str):
+    global _temp_dir
+    if _temp_dir is None:
+        _temp_dir = TemporaryDirectory()
+    threads = []
+    for target in targets:
+        thread = Thread(target=_ensure_ssh_control, args=(target,))
+        thread.start()
+        threads.append(thread)
+    for thread in threads:
+        thread.join()
+
+
+def ssh_args(target: str = "asylum") -> List[str]:
+    sock = _ensure_ssh_control(target)
+    return ["ssh", "-S", sock, target]
+
+
+def ssh_opt_args(target: str = "asylum") -> List[str]:
+    sock = _ensure_ssh_control(target)
+    return ["-o", f"ControlPath={sock}"]
+
+
+def scp(source: str, target: str):
+    if ":" in source:
+        host, _ = source.split(":", 1)
+    if ":" in target:
+        host, _ = target.split(":", 1)
+    p(["scp", source, target])
+    run_sc(["scp", *ssh_opt_args(host), source, target])
+
+
+def sftp_ls(path: str, target: str = "asylum", full: bool = False) -> List[str]:
+    p(["sftp", target, "ls", path])
+    ret = run_check(
+        ["sftp", "-b", "-", *ssh_opt_args(target), target],
+        input=f"ls -1 {path}",
+        capture_output=True,
+    )
+    output = ret.stdout.decode().splitlines()
+    if not full:
+        output = apply(output, lambda p: Path(p).name)
+    return output
+
+
+def sftp_exists(path: str, target: str = "asylum") -> bool:
+    p(["sftp", target, "exists", path])
+    return (
+        run_silent(
+            ["sftp", "-b", "-", *ssh_opt_args(target), target],
+            input=f"df {path}".encode(),
+        ).returncode
+        == 0
+    )
+
+
+def sftp_rm(path: str, target: str = "asylum"):
+    p(["sftp", target, "rm", path])
+    run_sc(
+        ["sftp", "-b", "-", *ssh_opt_args(target), target],
+        input=f"rm {path}".encode(),
+    )
+
+
+def sftp_mkdir(path: str, target: str = "asylum"):
+    p(["sftp", target, "mkdir", path])
+    run_sc(
+        ["sftp", "-b", "-", *ssh_opt_args(target), target],
+        input=f"mkdir {path}".encode(),
+    )
diff --git a/commia/util.py b/commia/util.py
new file mode 100644
index 0000000..6440adc
--- /dev/null
+++ b/commia/util.py
@@ -0,0 +1,77 @@
+import shlex
+import sys
+import termios
+import tty
+
+from commia.prelude import *
+
+
+def read_inilist(
+    filename: Union[str, Path], init: List[str] = ["default"]
+) -> Dict[str, Set[str]]:
+    sections = {}
+    for key in init:
+        sections[key] = set()
+    section = "default"
+    for line in Path(filename).read_text().splitlines():
+        line = line.split(";")[0].strip()
+        if len(line) == 0:
+            continue
+        if line.startswith("[") and line.endswith("]"):
+            section = line[1:-1]
+            if not section in sections:
+                sections[section] = set()
+            continue
+        sections[section].add(line)
+    return sections
+
+
+def read_ini(
+    filename: Union[str, Path], init: List[str] = ["default"]
+) -> Dict[str, Dict[str, str]]:
+    sections = {}
+    for key in init:
+        sections[key] = {}
+    section = "default"
+    for line in Path(filename).read_text().splitlines():
+        line = line.split(";")[0].strip()
+        if len(line) == 0:
+            continue
+        if line.startswith("[") and line.endswith("]"):
+            section = line[1:-1]
+            if not section in sections:
+                sections[section] = {}
+            continue
+        key, value = line.split("=", 1)
+        sections[section][key.strip()] = value.strip()
+    return sections
+
+
+# from https://code.activestate.com/recipes/134892/
+def getch() -> str:
+    sys.stdout.flush()
+    fd = sys.stdin.fileno()
+    old_settings = termios.tcgetattr(fd)
+    try:
+        tty.setraw(sys.stdin.fileno())
+        ch = sys.stdin.read(1)
+    finally:
+        termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
+    sys.stdout.write(ch)
+    return ch
+
+
+def check_continue(prompt="continue?", *, default=False) -> bool:
+    y = "Y" if default else "y"
+    n = "n" if default else "N"
+    print(f"{prompt} [{y}/{n}] ", end="")
+    res = getch().lower()
+    print()
+    return res == "y" or (res != "n" and default)
+
+
+def with_written(data: bytes | str, callback: Callable[[str], None]):
+    with NamedTemporaryFile("w" if type(data) == str else "wb") as tmp:
+        tmp.write(data)
+        tmp.flush()
+        callback(tmp.name)