diff options
author | Florian Dold <florian@dold.me> | 2023-04-17 23:42:02 +0200 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2023-04-17 23:42:11 +0200 |
commit | ae6d881a9fd56598005f90963609291d5fa5bdb3 (patch) | |
tree | d3097a1652ec0edaae03df3c14ae1ec8d159dfcc | |
parent | dd24c764f70b39820622fa274ee71c87fc5e93bf (diff) | |
download | deployment-ae6d881a9fd56598005f90963609291d5fa5bdb3.tar.gz deployment-ae6d881a9fd56598005f90963609291d5fa5bdb3.tar.bz2 deployment-ae6d881a9fd56598005f90963609291d5fa5bdb3.zip |
splitops
-rw-r--r-- | splitops/README.md | 40 | ||||
-rwxr-xr-x | splitops/splitops | 143 |
2 files changed, 183 insertions, 0 deletions
diff --git a/splitops/README.md b/splitops/README.md new file mode 100644 index 0000000..a349ce3 --- /dev/null +++ b/splitops/README.md @@ -0,0 +1,40 @@ +# splitops + +Splitops is a script to allow execution of commands only after the approval of +multiple users. + +It is intended to be used with OpenSSH by specifiying it as the "command" option +for authorized users in `~/.ssh/authorized_keys`. + +For example, consider following `authorized_keys` file for the user `root`: + +``` +command="/bin/splitops --user=alice" [... key of alice ...] +command="/bin/splitops --user=bob" [... key of bob ...] +``` + +This allows Alice and Bob to jointly run commands: + +``` +bob$ ssh root@server propose rm -rf /opt/something +authenticated as: bob +requested command: ['rm', '-rf', '/opt/something'] +assigned id: ccafbd + +bob$ ssh root@server approve ccafbd + +alice$ ssh root@server get +{'cmd': ['rm', '-rf', '/opt/something'], 'request_id': 'ccafbd'} + + +alice$ ssh root@server approve ccafbd + +bob$ ssh root@server run ccafbd +==stdout== +... +==== +==stderr== +... +==== +exit status: 0 +``` diff --git a/splitops/splitops b/splitops/splitops new file mode 100755 index 0000000..8e5414c --- /dev/null +++ b/splitops/splitops @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 + +""" +This script is intended to be used as a SSH command wrapper. + +It allows users to propose a command that should be run. +The command will only be executed after a threshold of +other users has approved the command. +""" + +import os +import shlex +import sys +import json +from pathlib import Path +import uuid +from dataclasses import dataclass +import subprocess + +# Approval threshold, including the approval +# of the proposer. +APPROVAL_THRESHOLD = 2 + +cmdpath = Path.home() / "cmd.json" + +def write_cmd(d): + with open(cmdpath, "w") as f: + f.write(json.dumps(d)) + +def read_cmd(): + try: + with open(cmdpath, "r") as f: + return json.load(f) + except FileNotFoundError: + return None + +def propose(cmd): + request_id = uuid.uuid4().hex.lower()[0:6] + for x in cmd: + if not x.isascii(): + print("requested command not ascii") + sys.exit(4) + print(f"requested command: {cmd}") + write_cmd({"cmd": cmd, "request_id": request_id}) + print(f"assigned id: {request_id}") + +def approve(my_user, request_id): + print(f"approving command {request_id} as {my_user}") + d = read_cmd() + if d is None: + print("no command proposed") + sys.exit(1) + if d["request_id"] != request_id: + print("request ID does not match") + sys.exit(1) + approved_by = d.get("approved_by", []) + if my_user not in approved_by: + approved_by.append(my_user) + d["approved_by"] = approved_by + write_cmd(d) + +def run(request_id): + print(f"running command with ID {request_id}") + d = read_cmd() + if d is None: + print("no command proposed") + sys.exit(1) + if d["request_id"] != request_id: + print("request ID does not match") + sys.exit(1) + approved_by = d.get("approved_by", []) + num_approvals = len(approved_by) + if num_approvals < APPROVAL_THRESHOLD: + print(f"not enough approvals, got {num_approvals} but need {APPROVAL_THRESHOLD}") + sys.exit(1) + if d.get("executed", False): + print("command has already been executed once, please request again") + sys.exit(1) + cmd = d["cmd"] + d["executed"] = True + # Mark as executed, can only execute once! + write_cmd(d) + print("running command", cmd) + res = subprocess.run(cmd, capture_output=True, encoding="utf-8") + print(f"==stdout==\n{res.stdout}====") + print(f"==stderr==\n{res.stderr}====") + print(f"exit code: {res.returncode}") + # FIXME: Write log to disk? + + +def usage(): + print("Commands:") + print(" whoami: Check authentication.") + print(" propose CMD...: Propose a new command.") + print(" get: Get the currently proposed command.") + print(" approve CMDID: Approve a command.") + print(" run CMDID: Run a sufficiently approved command.") + print(" discard: Discard the currently proposed command.") + sys.exit(1) + +def die(msg): + printf(msg) + sys.exit(2) + +def main(): + if len(sys.argv) != 2: + die("unexpected usage") + user = sys.argv[1] + os_user = os.environ["USER"] + print(f"authenticated as: {user}") + inner_cmd = os.environ.get("SSH_ORIGINAL_COMMAND") + if inner_cmd is None: + print("no command provided, try help") + sys.exit(3) + inner_args = shlex.split(inner_cmd) + if len(inner_args) < 1: + usage() + subcommand = inner_args[0] + if subcommand == "discard": + cmdpath.unlink() + elif subcommand == "whoami": + print(f"you are {user} on {os_user}") + elif subcommand == "propose": + propose(inner_args[1:]) + elif subcommand == "get": + print(read_cmd()) + elif subcommand == "help": + usage() + elif subcommand == "run": + if len(inner_args) != 2: + usage() + run(inner_args[1]) + elif subcommand == "approve": + if len(inner_args) != 2: + usage() + approve(user, inner_args[1]) + else: + print(f"unknown subcommand {subcommand}") + usage() + +if __name__ == '__main__': + main() + |