From a0564432245d176c20a00be45527a1bdf9f8013d Mon Sep 17 00:00:00 2001 From: Fenris Wolf Date: Wed, 29 Apr 2026 23:46:44 +0200 Subject: [PATCH] [ini] --- .gitignore | 1 + misc/put | 21 ++++ readme.md | 25 ++++ source/helpers/keepass.py | 139 +++++++++++++++++++++++ source/helpers/misc.py | 50 ++++++++ source/helpers/ssh.py | 107 +++++++++++++++++ source/main.py | 233 ++++++++++++++++++++++++++++++++++++++ tools/build | 32 ++++++ 8 files changed, 608 insertions(+) create mode 100644 .gitignore create mode 100755 misc/put create mode 100644 readme.md create mode 100644 source/helpers/keepass.py create mode 100644 source/helpers/misc.py create mode 100644 source/helpers/ssh.py create mode 100755 source/main.py create mode 100755 tools/build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d94939 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.geany diff --git a/misc/put b/misc/put new file mode 100755 index 0000000..670595d --- /dev/null +++ b/misc/put @@ -0,0 +1,21 @@ +#!/usr/bin/env sh + +## consts +path_conf_source="build/config" +path_keys_source="build/keypairs" + +## vars +datestring=$(date +%Y-%m-%d) +path_conf_target="${HOME}/.ssh/config" +path_conf_backup="${path_conf_target}.${datestring}.bak" +path_keys_target="${HOME}/.ssh/keypairs" +path_keys_backup="${HOME}/.ssh/keypairs.${datestring}.bak" + +## exec +test -e ${path_conf_target} || cp -v ${path_conf_target} ${path_conf_backup} +mkdir -p $(dirname ${path_conf_target}) +cp -v ${path_conf_source} ${path_conf_target} + +test -e ${path_keys_target} || (mkdir -p ${path_keys_backup} && cp -r -u -v ${path_keys_target}/* ${path_keys_backup}/) +mkdir -p ${path_keys_target} +cp -r -u -v ${path_keys_source}/* ${path_keys_target}/ diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..8177e22 --- /dev/null +++ b/readme.md @@ -0,0 +1,25 @@ +# bifroyst + +## Erstellung + +### Voraussetzungen + +(keine) + + +### Anweisungen + +- `tools/build` ausführen + + +### Betrieb + +### Voraussetzungen + +- Python3 +- KeepassXC + + +### Anweisungen + +- siehe `bifroyst -h` diff --git a/source/helpers/keepass.py b/source/helpers/keepass.py new file mode 100644 index 0000000..2727e56 --- /dev/null +++ b/source/helpers/keepass.py @@ -0,0 +1,139 @@ +import helpers.misc as __helpers_misc + + +def compose_entry( + group_name, + member_name +): + return __helpers_misc.string_coin( + "{{group}}/{{member}}", + { + "group": group_name, + "member": member_name, + } + ) + + +def macro_auth( + key_file +): + return ( + "" + if + (key_file is None) + else + __helpers_misc.string_coin( + " --no-password --key-file={{authfile_path}}", + { + "authfile_path": key_file, + } + ) + ) + + +def action_db_create( + db_path, + options = None +): + options = ( + { + "key_file": None, + } + | + (options or {}) + ) + __helpers_misc.shell_exec( + __helpers_misc.string_coin( + "keepassxc-cli db-create{{macro_auth}} {{db_path}}", + { + "db_path": db_path, + "macro_auth": ( + "" + if + (options["key_file"] is None) + else + __helpers_misc.string_coin( + " --set-key-file={{authfile_path}}", + { + "authfile_path": options["key_file"], + } + ) + ) + } + ) + ) + + +def action_mkdir( + db_path, + group_name, + options = None +): + __helpers_misc.shell_exec( + __helpers_misc.string_coin( + "keepassxc-cli mkdir{{macro_auth}} {{db_path}} {{group_name}}", + { + "db_path": db_path, + "group_name": group_name, + "macro_auth": macro_auth(options["key_file"]), + } + ) + ) + + +def action_add( + db_path, + group_name, + member_name, + options = None +): + __helpers_misc.shell_exec( + __helpers_misc.string_coin( + "keepassxc-cli add{{macro_auth}} {{db_path}} {{entry}}", + { + "db_path": db_path, + "entry": compose_entry(group_name, member_name), + "macro_auth": macro_auth(options["key_file"]), + } + ) + ) + + +def action_rm( + db_path, + group_name, + member_name, + options = None +): + __helpers_misc.shell_exec( + __helpers_misc.string_coin( + "keepassxc-cli rm{{macro_auth}} {{db_path}} {{entry}}", + { + "db_path": db_path, + "entry": compose_entry(group_name, member_name), + "macro_auth": macro_auth(options["key_file"]), + } + ) + ) + + +def action_attachment_import( + db_path, + group_name, + member_name, + attachment_label, + attachment_path, + options = None +): + __helpers_misc.shell_exec( + __helpers_misc.string_coin( + "keepassxc-cli attachment-import{{macro_auth}} {{db_path}} {{entry}} '{{attachment_label}}' {{attachment_path}}", + { + "db_path": db_path, + "entry": compose_entry(group_name, member_name), + "attachment_label": attachment_label, + "attachment_path": attachment_path, + "macro_auth": macro_auth(options["key_file"]), + } + ) + ) diff --git a/source/helpers/misc.py b/source/helpers/misc.py new file mode 100644 index 0000000..0ac4647 --- /dev/null +++ b/source/helpers/misc.py @@ -0,0 +1,50 @@ +import sys as _sys +import os as _os + + +def convey(value, functions): + result = value + for function in functions: + result = function(result) + return result + + +def string_coin(template, arguments): + result = template + for (key, value, ) in arguments.items(): + result = result.replace("{{%s}}" % key, value) + return result + + +def file_read(path): + handle = open(path, "r") + content = handle.read() + handle.close() + return content + + +def file_write(path, content): + handle = open(path, "w") + handle.write(content) + handle.close() + + +def directory_create(path): + steps = _os.path.split(path) + for index in range(len(steps)): + path_ = _os.path.join(*(steps[:(index+1)])) + if (path_ == ""): + pass + else: + if (_os.path.exists(path_)): + pass + else: + _os.mkdir(path_) + + +def shell_exec( + command +): + _sys.stderr.write("\n>> %s\n" % command) + _os.system(command) + diff --git a/source/helpers/ssh.py b/source/helpers/ssh.py new file mode 100644 index 0000000..885976c --- /dev/null +++ b/source/helpers/ssh.py @@ -0,0 +1,107 @@ +import helpers.misc as __misc + + +def sshconf_decode_bool( + value +): + return (value == "yes") + + +def sshconf_decode( + sshconf +): + state = { + "name": "initial", + "entries": {}, + "current_name": None, + "current_entry": None, + } + for line in sshconf.split("\n"): + line_stripped = line.strip() + if (line_stripped == ""): + pass + else: + parts = line_stripped.split(" ") + head = parts[0] + body = " ".join(parts[1:]) + if (head == "#"): + state["current_entry"]["extra"] = ( + [body] + if ("extra" not in state["current_entry"]) else + (state["current_entry"]["extra"] + [body]) + ) + elif (head.lower() == "host"): + if ((state["current_name"] is None) or (state["current_entry"] is None)): + pass + else: + state["entries"][state["current_name"]] = state["current_entry"] + state["current_name"] = body + state["current_entry"] = {} + elif (head.lower() == "hostname"): + state["current_entry"]["host"] = body + elif (head.lower() == "port"): + state["current_entry"]["port"] = int(body) + elif (head.lower() == "user"): + state["current_entry"]["username"] = body + elif (head.lower() == "identityfile"): + state["current_entry"]["key_name"] = body + elif (head.lower() == "proxyjump"): + state["current_entry"]["proxy_jump"] = body + elif (head.lower() == "proxycommand"): + state["current_entry"]["proxy_command"] = body + elif (head.lower() == "pubkeyauthentication"): + state["current_entry"]["pub_key_authentication"] = sshconf_decode_bool(body) + elif (head.lower() == "identitiesonly"): + state["current_entry"]["identities_only"] = sshconf_decode_bool(body) + else: + raise ValueError("unhandled sshconf thing: " + head) + state["entries"][state["current_name"]] = state["current_entry"] + state["current_name"] = None + state["current_entry"] = None + return state["entries"] + + +def sshconf_encode_bool( + value +): + return ("yes" if value else "no") + + +def sshconf_encode_entry(name, entry, prefix): + lines = [] + lines.append(__misc.string_coin("Host {{prefix}}{{name}}", {"prefix": prefix, "name": name})) + if ("host" in entry): + lines.append(__misc.string_coin("\tHostName {{host}}", {"host": entry["host"]})) + if ("port" in entry): + lines.append(__misc.string_coin("\tPort {{port}}", {"port": ("%u" % entry["port"])})) + if ("username" in entry): + lines.append(__misc.string_coin("\tUser {{username}}", {"username": entry["username"]})) + if ("key_name" in entry): + lines.append(__misc.string_coin("\tIdentityFile {{key_path}}", {"key_path": ("~/.ssh/keypairs/%s%s" % (prefix, entry["key_name"]))})) + if ("proxy_jump" in entry): + lines.append(__misc.string_coin("\tProxyJump {{proxy_jump}}", {"proxy_jump": entry["proxy_jump"]})) + if ("proxy_command" in entry): + lines.append(__misc.string_coin("\tProxyCommand {{proxy_command}}", {"proxy_command": entry["proxy_command"]})) + if ("pub_key_authentication" in entry): + lines.append(__misc.string_coin("\tPubKeyAuthentication {{pub_key_authentication}}", {"pub_key_authentication": sshconf_encode_bool(entry["pub_key_authentication"])})) + if ("identities_only" in entry): + lines.append(__misc.string_coin("\tIdentitiesOnly {{identities_only}}", {"identities_only": sshconf_encode_bool(entry["identities_only"])})) + lines.append("") + return "\n".join(lines) + + +def sshconf_encode(conf): + return "\n".join( + map( + lambda pair: sshconf_encode_entry( + pair[0], + pair[1], + conf["settings"]["prefix"] + ), + sorted( + conf["entries"].items(), + key = lambda entry: entry[0] + ) + ) + ) + diff --git a/source/main.py b/source/main.py new file mode 100755 index 0000000..2f57c6d --- /dev/null +++ b/source/main.py @@ -0,0 +1,233 @@ +import sys as _sys +import os as _os +import json as _json +import shutil as _shutil +import argparse as _argparse + +import helpers.misc as __helpers_misc +import helpers.ssh as __helpers_ssh +import helpers.keepass as __helpers_keepass + + +def action_init( + source_directory +): + keepass_db_path = _os.path.join(source_directory, "private_keys.kdbx") + keepass_authfile_path = _os.path.join(source_directory, "private_keys.keyx") + __helpers_misc.directory_create(source_directory) + __helpers_misc.shell_exec( + __helpers_misc.string_coin( + "openssl rand -out {{authfile_path}} 256", + { + "authfile_path": keepass_authfile_path, + } + ) + ) + __helpers_keepass.action_db_create( + keepass_db_path, + { + "key_file": keepass_authfile_path, + } + ) + + +def action_key_add( + source_directory, + group, + name +): + directory_key = _os.path.join(source_directory, group, "keys") + path_key_private = _os.path.join(directory_key, name) + path_key_public = ("%s.pub" % path_key_private) + __helpers_misc.directory_create(directory_key) + ## generate + __helpers_misc.shell_exec( + __helpers_misc.string_coin( + "ssh-keygen -t {{encryption_type}} -f {{path}}", + { + "encryption_type": "ed25519", + "path": path_key_private, + } + ) + ) + ## transfer private key to keepass database + if True: + keepass_db_path = _os.path.join(source_directory, "private_keys.kdbx") + keepass_authfile_path = _os.path.join(source_directory, "private_keys.keyx") + __helpers_keepass.action_mkdir( + keepass_db_path, + group, + { + "key_file": keepass_authfile_path, + } + ) + __helpers_keepass.action_add( + keepass_db_path, + group, + name, + { + "key_file": keepass_authfile_path, + } + ) + __helpers_keepass.action_attachment_import( + keepass_db_path, + group, + name, + 'ssh private key', + path_key_private, + { + "key_file": keepass_authfile_path, + } + ) + ## remove private key file + __helpers_misc.shell_exec( + __helpers_misc.string_coin( + "rm --force {{path}}", + { + "path": path_key_private, + } + ) + ) + +def action_key_remove( + source_directory, + group, + name +): + path_key_private = _os.path.join(source_directory, group, "keys", name) + path_key_public = ("%s.pub" % path_key_private) + ## remove private key from keepass database + if True: + keepass_db_path = _os.path.join(source_directory, "private_keys.kdbx") + keepass_authfile_path = _os.path.join(source_directory, "private_keys.keyx") + __helpers_keepass.action_rm( + keepass_db_path, + group, + name, + { + "key_file": keepass_authfile_path, + } + ) + ## remove public key file + __helpers_misc.shell_exec( + __helpers_misc.string_coin( + "rm --force {{path}}", + { + "path": path_key_public, + } + ) + ) + + +''' +todo: backup (see old put script) +''' +def action_put( + source_directory, + target_directory +): + sshconf = "" + for group in _os.listdir(source_directory): + conf_path = _os.path.join(source_directory, group, "conf.json") + conf_content = __helpers_misc.file_read(conf_path) + conf_data = _json.loads(conf_content) + # conf + if True: + sshconf = (sshconf + "\n" + __helpers_ssh.sshconf_encode(conf_data)) + # public keys + if True: + __helpers_misc.directory_create(target_directory) + for name in _os.listdir(_os.path.join(source_directory, group, "keys")): + _shutil.copy( + _os.path.join(source_directory, group, "keys", name), + _os.path.join(target_directory, "%s%s" % (conf_data["settings"]["prefix"], name, )) + ) + # private keys + if True: + keepass_db_path = _os.path.join(source_directory, "private_keys.kdbx") + _shutil.copy( + keepass_db_path, + _os.path.join(target_directory, "private_keys.kdbx") + ) + ## todo: keyfile + __helpers_misc.file_write(_os.path.join(target_directory, "config"), sshconf) + + +def main(): + ## consts + dir_conf = "source/conf" + path_build = "build/config" + + ## args + argument_parser = _argparse.ArgumentParser( + prog = "bifroyst", + description = "SSH connection manager", + ) + argument_parser.add_argument( + "action", + type = str, + choices = [ + "init", + "key-add", + "key-remove", + "put", + ], + default = "put", + metavar = "", + help = "options: init | key-add | key-remove | put", + ) + argument_parser.add_argument( + "-s", + "--source-directory", + type = str, + default = ".", + metavar = "", + ) + argument_parser.add_argument( + "-t", + "--target-directory", + type = str, + default = "~/.ssh", + metavar = "", + ) + argument_parser.add_argument( + "-g", + "--group", + type = str, + default = "default", + metavar = "", + ) + argument_parser.add_argument( + "-n", + "--name", + type = str, + default = None, + metavar = "", + ) + args = argument_parser.parse_args() + + ## exec + if (args.action == "init"): + action_init( + args.source_directory + ) + elif (args.action == "key-add"): + action_key_add( + args.source_directory, + args.group, + args.name + ) + elif (args.action == "key-remove"): + action_key_remove( + args.source_directory, + args.group, + args.name + ) + elif (args.action == "put"): + action_put( + args.source_directory, + args.target_directory + ) + else: + raise ValueError("invalid action: %s", args.action) + diff --git a/tools/build b/tools/build new file mode 100755 index 0000000..8c45599 --- /dev/null +++ b/tools/build @@ -0,0 +1,32 @@ +#!/usr/bin/env sh + +## consts + +dir_source=source +dir_temp=/tmp/bifroyst-temp +dir_build=/tmp/bifroyst + + +## exec + +### exec + +path_app=${dir_build}/bifroyst + +rm ${dir_temp} --force --recursive +mkdir ${dir_temp} --parents +cp ${dir_source}/. ${dir_temp}/ --recursive --update +for dir in $(find ${dir_temp} -mindepth 1 -type d) ; do touch ${dir}/__init__.py ; done +echo '' > ${dir_temp}/__main__.py +echo 'from main import *' >> ${dir_temp}/__main__.py +echo 'if __name__ == "__main__": main()' >> ${dir_temp}/__main__.py + +mkdir ${dir_build} --parents +# rm ${path_app}.zip --force +cd ${dir_temp} && python3 -m zipfile -c ${path_app}.zip . ; cd - > /dev/null +echo '#!/usr/bin/env python3' > ${path_app} +cat ${path_app}.zip >> ${path_app} +rm ${path_app}.zip +chmod +x ${path_app} + +echo "-- ${dir_build}"