diff options
author | mia <mia@mia.jetzt> | 2024-06-08 22:55:38 -0700 |
---|---|---|
committer | mia <mia@mia.jetzt> | 2024-06-08 22:55:38 -0700 |
commit | 3951bfb7c042ada824a66b420703b7587de48e5c (patch) | |
tree | 188299df2f35aa0ffe1e7808101e3aff491dbcc0 | |
download | commia-3951bfb7c042ada824a66b420703b7587de48e5c.tar.gz commia-3951bfb7c042ada824a66b420703b7587de48e5c.zip |
initial commit
-rw-r--r-- | .envrc | 2 | ||||
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | commia/__init__.py | 0 | ||||
-rw-r--r-- | commia/bearer.py | 99 | ||||
-rw-r--r-- | commia/exit.py | 25 | ||||
-rw-r--r-- | commia/prelude.py | 31 | ||||
-rw-r--r-- | commia/ssh.py | 122 | ||||
-rw-r--r-- | commia/util.py | 77 |
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) |