From 6a7f081f12e38c2ffc4b4e90bd8baeef37efca5d Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sat, 25 Apr 2026 17:53:33 +0200 Subject: [PATCH] Added Reticulum Git Node utility as part of included utility programs. Added git remote helper to interact with git repositories over Reticulum. --- RNS/Utilities/rngit/__init__.py | 39 ++ RNS/Utilities/rngit/client.py | 553 ++++++++++++++++++++++++++ RNS/Utilities/rngit/main.py | 40 ++ RNS/Utilities/rngit/server.py | 677 ++++++++++++++++++++++++++++++++ 4 files changed, 1309 insertions(+) create mode 100644 RNS/Utilities/rngit/__init__.py create mode 100644 RNS/Utilities/rngit/client.py create mode 100644 RNS/Utilities/rngit/main.py create mode 100644 RNS/Utilities/rngit/server.py diff --git a/RNS/Utilities/rngit/__init__.py b/RNS/Utilities/rngit/__init__.py new file mode 100644 index 0000000..56fbb79 --- /dev/null +++ b/RNS/Utilities/rngit/__init__.py @@ -0,0 +1,39 @@ +# Reticulum License +# +# Copyright (c) 2016-2026 Mark Qvist +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# - The Software shall not be used in any kind of system which includes amongst +# its functions the ability to purposefully do harm to human beings. +# +# - The Software shall not be used, directly or indirectly, in the creation of +# an artificial intelligence, machine learning or language model training +# dataset, including but not limited to any use that contributes to the +# training or development of such a model or algorithm. +# +# - The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +APP_NAME = "git" + +import os +import glob + +py_modules = glob.glob(os.path.dirname(__file__)+"/*.py") +pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc") +modules = py_modules+pyc_modules +__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))])) \ No newline at end of file diff --git a/RNS/Utilities/rngit/client.py b/RNS/Utilities/rngit/client.py new file mode 100644 index 0000000..acc6048 --- /dev/null +++ b/RNS/Utilities/rngit/client.py @@ -0,0 +1,553 @@ +#!/usr/bin/env python3 + +# Reticulum License +# +# Copyright (c) 2016-2026 Mark Qvist +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# - The Software shall not be used in any kind of system which includes amongst +# its functions the ability to purposefully do harm to human beings. +# +# - The Software shall not be used, directly or indirectly, in the creation of +# an artificial intelligence, machine learning or language model training +# dataset, including but not limited to any use that contributes to the +# training or development of such a model or algorithm. +# +# - The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import RNS +import os +import sys +import time +import shutil +import threading +import subprocess + +from RNS._version import __version__ +from RNS.Utilities.rngit import APP_NAME + +from RNS.vendor.configobj import ConfigObj +from tempfile import TemporaryDirectory + +def program_setup(configdir, rnsconfigdir, destination_hexhash, group_name, repo_name): + git_client = ReticulumGitClient(configdir=configdir, rnsconfigdir=rnsconfigdir, destination_hexhash=destination_hexhash, + group_name=group_name, repo_name=repo_name) + + if not git_client.ready: sys.exit(1) + else: git_client.run() + +def main(): + if len(sys.argv) < 3: + print("Usage: git-remote-rns ", file=sys.stderr) + sys.exit(1) + + url = sys.argv[2] + if not url.startswith("rns://"): + print("Invalid URL scheme. Must be rns://", file=sys.stderr) + sys.exit(1) + + try: + parts = url[6:].split("/", 2) + destination_hexhash = parts[0] + group_name = parts[1] + repo_name = parts[2] + + except IndexError: print("Invalid URL format. Use rns:////", file=sys.stderr); sys.exit(1) + + configdir = os.environ.get("RNGIT_CONFIG", None) + rnsconfigdir = os.environ.get("RNS_CONFIG", None) + + program_setup(configdir, rnsconfigdir, destination_hexhash, group_name, repo_name) + exit(0) + + +class ReticulumGitClient(): + PATH_LIST = "/git/list" + PATH_FETCH = "/git/fetch" + PATH_PUSH = "/git/push" + PATH_DELETE = "/git/delete" + + RES_DISALLOWED = 0x01 + RES_INVALID_REQ = 0x02 + RES_NOT_FOUND = 0x03 + RES_REMOTE_FAIL = 0xFF + + IDX_REPOSITORY = 0x00 + IDX_RESULT_CODE = 0x01 + + REF_BATCH_SIZE = 25 + PATH_TIMEOUT = 15 + LINK_TIMEOUT = 15 + + def __init__(self, configdir, rnsconfigdir, destination_hexhash, group_name, repo_name): + # Client state and configuration + self.identity = None + self.userdir = os.path.expanduser("~") + self.config = None + self.ready = False + + self.remote_identity = None + self.destination = None + self.link = None + self.link_ready = False + self.link_failed = False + self.link_timeout = self.LINK_TIMEOUT + self.path_timeout = self.PATH_TIMEOUT + + self.destination_hexhash = destination_hexhash + self.group_name = group_name + self.repo_name = repo_name + self.repo_path = f"{group_name}/{repo_name}" + + self.tmp_dir = TemporaryDirectory() + self.request_event = threading.Event() + self.request_response = None + self.response_metadata = None + + self.ref_batch_size = self.REF_BATCH_SIZE + + # Response progress tracking + self.response_progress = 0 + self.previous_progress = 0 + self.response_size = None + self.response_transfer_size = None + self.progress_updated_at = None + self.progress_enabled = False + + if configdir != None: self.configdir = configdir + else: + if os.path.isdir(self.userdir+"/.config/rngit") and os.path.isfile(self.userdir+"/.config/rngit/config"): self.configdir = self.userdir+"/.rngit/reticulum" + else: self.configdir = self.userdir+"/.rngit" + + self.logfile = self.configdir+"/client_log" + self.configpath = self.configdir+"/client_config" + self.identitypath = self.configdir+"/client_identity" + + RNS.logfile = self.logfile + try: self.reticulum = RNS.Reticulum(configdir=rnsconfigdir, logdest=RNS.LOG_FILE) + except Exception as e: + print(f"Failed to initialize Reticulum: {e}", file=sys.stderr) + return + + if os.path.isfile(self.configpath): + try: self.config = ConfigObj(self.configpath) + except Exception as e: + RNS.log("Could not parse the configuration at "+self.configpath, RNS.LOG_ERROR) + return + + else: self.__create_default_config() + + self.__apply_config() + self.ready = True + + def __create_default_config(self): + self.config = ConfigObj(__default_rngit_config__) + self.config.filename = self.configpath + if not os.path.isdir(self.configdir): os.makedirs(self.configdir) + self.config.write() + + def __apply_config(self): + if "logging" in self.config: + section = self.config["logging"] + if "loglevel" in section: RNS.loglevel = max(RNS.LOG_NONE, min(RNS.LOG_EXTREME, section.as_int("loglevel"))) + + if "client" in self.config: + section = self.config["client"] + if "ref_batch_size" in section: self.ref_batch_size = max(0, min(1024, section.as_int("ref_batch_size"))) + + if not os.path.isfile(self.identitypath): + identity = RNS.Identity() + identity.to_file(self.identitypath) + RNS.log(f"Client identity generated and persisted to {self.identitypath}", RNS.LOG_VERBOSE) + + else: + identity = RNS.Identity.from_file(self.identitypath) + RNS.log(f"Client identity loaded from {self.identitypath}", RNS.LOG_VERBOSE) + + if not identity: + RNS.log("Could not initialize client identity.", RNS.LOG_ERROR) + self.ready = False + + else: self.identity = identity + + def abort(self, reason=None, code=255): + if not reason: reason = "Unknown reason" + print(f"git-remote-rns failed: {reason}", file=sys.stderr) + if self.link: self.link.teardown() + sys.exit(code) + + def connect_server(self): + try: destination_hash = bytes.fromhex(self.destination_hexhash) + except Exception as e: self.abort(f"Invalid destination hash: {e}") + + RNS.log(f"Requesting path to {RNS.prettyhexrep(destination_hash)}", RNS.LOG_DEBUG) + if not RNS.Transport.await_path(destination_hash, timeout=self.path_timeout): self.abort(f"Could not resolve path to {RNS.prettyhexrep(destination_hash)}") + else: RNS.log(f"Path to {RNS.prettyhexrep(destination_hash)} resolved", RNS.LOG_DEBUG) + + self.remote_identity = RNS.Identity.recall(destination_hash) + if not self.remote_identity: self.abort("Could not recall remote identity. Is the server announcing?") + + self.destination = RNS.Destination(self.remote_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, APP_NAME, "repositories") + self.link = RNS.Link(self.destination) + self.link.set_link_established_callback(self.link_established) + self.link.set_link_closed_callback(self.link_closed) + + def link_established(self, link): + RNS.log(f"Link established, identifying...", RNS.LOG_DEBUG) + link.identify(self.identity) + self.link_ready = True + + def link_closed(self, link): + RNS.log(f"Link was closed", RNS.LOG_DEBUG) + if not self.link_ready: self.link_failed = True + + def _on_progress(self, request_receipt): + self.response_progress = request_receipt.progress + self.response_size = request_receipt.response_size + self.response_transfer_size = request_receipt.response_transfer_size + + now = time.time() + if self.progress_updated_at == None: self.progress_updated_at = now + + if now > self.progress_updated_at+1: + td = now - self.progress_updated_at + pd = self.response_progress - self.previous_progress + bd = pd*self.response_size if self.response_size else 0 + self.response_speed = (bd/td)*8 if td > 0 else 0 + self.previous_progress = self.response_progress + self.progress_updated_at = now + + # Report progress to git via stderr + if self.progress_enabled and self.response_size: + percent = round(self.response_progress * 100, 1) + size = self.response_size + rxd = size*self.response_progress + speed_kbps = (self.response_speed / 1024) if hasattr(self, 'response_speed') else 0 + sys.stderr.write(f"Transferring: {percent}% ({RNS.prettysize(rxd)}/{RNS.prettysize(size)}) {RNS.prettyspeed(self.response_speed)} \r") + sys.stderr.flush() + + ################################ + # Synchronous Request Wrappers # + ################################ + + def _response_ready(self, request_receipt): + self.request_response = request_receipt.response + self.response_metadata = request_receipt.metadata + + if hasattr(self.request_response, "read") and callable(self.request_response.read): + response_path = self.request_response.name + base_name = os.path.basename(response_path) + retained_path = os.path.join(self.tmp_dir.name, base_name) + shutil.move(response_path, retained_path) + self.request_response = open(retained_path, "rb") + + self.request_event.set() + + def _response_failed(self, request_receipt=None): + self.request_response = None + self.request_event.set() + + def send_request(self, path, data, timeout=7200): + if not self.link_ready: self.abort("Link not ready for request") + + self.request_event.clear() + self.request_response = None + self.response_metadata = None + + RNS.log(f"Sending request: {path}", RNS.LOG_DEBUG) + self.link.request(path, data, progress_callback=self._on_progress, response_callback=self._response_ready, failed_callback=self._response_failed, timeout=timeout) + self.request_event.wait(timeout=timeout) + + if self.request_response is None: self.abort("Request failed or timed out") + RNS.log(f"Got response for: {path}", RNS.LOG_DEBUG) + + return self.request_response, self.response_metadata + + ############################# + # Git Helper Protocol Logic # + ############################# + + def _detach_stdout(self): + sys.stdout = open(os.devnull, "w") + sys.stderr = open(os.devnull, "w") + + def run(self): + try: self.connect_server() + except Exception as e: self.abort(str(e)) + + timeout = self.link_timeout + while not self.link_ready and not self.link_failed and timeout > 0: + time.sleep(0.5) + timeout -= 1 + + if not self.link_ready: self.abort("Failed to establish link") + + self.progress_enabled = False + + git_stdin = sys.stdin + git_stdout = sys.stdout + git_stderr = sys.stderr + + fetch_queue = [] + push_queue = [] + + while True: + line = git_stdin.readline() + if not line: break + + line = line.strip() + if line == "capabilities": + git_stdout.write("list\n") + git_stdout.write("fetch\n") + git_stdout.write("push\n") + git_stdout.write("option\n") + git_stdout.write("\n") + git_stdout.flush() + + elif line == "list": self.handle_git_list(git_stdout) + + elif line.startswith("list "): self.handle_git_list(git_stdout, for_push=True) # List for push + + elif line.startswith("option"): + # Line format: option + parts = line.split(maxsplit=2) + opt_name = parts[1] if len(parts) > 1 else "" + opt_value = parts[2] if len(parts) > 2 else "" + + if opt_name == "progress": self.progress_enabled = opt_value.lower() in ("true", "1", "yes"); git_stdout.write("ok\n") + else: git_stdout.write("unsupported\n") + + git_stdout.flush() + + elif line.startswith("fetch"): + # Line format: fetch + parts = line.split() + sha = parts[1] + ref = parts[2] + # Avoid duplicates in the same batch - TODO: Re-evaluate this + if (sha, ref) not in fetch_queue: fetch_queue.append((sha, ref)) + push_queue = [] + + elif line.startswith("push"): + # Line format: push : + parts = line.split() + refspec = parts[1] + local_ref, remote_ref = refspec.split(":", 1) + push_queue.append((local_ref, remote_ref)) + fetch_queue = [] + + elif line == "": # End of batch + try: + self.process_fetch_queue(fetch_queue, git_stdout, self.progress_enabled, self.ref_batch_size) + self.process_push_queue(push_queue, git_stdout, git_stderr, self.progress_enabled) + fetch_queue = [] + push_queue = [] + git_stdout.write("\n") + git_stdout.flush() + + except BrokenPipeError: + self._detach_stdout() + RNS.log("Git closed connection, exiting", RNS.LOG_DEBUG) + break + + else: self.abort(f"Unknown Git command: {line}") + + try: sys.stdout.flush() + except BrokenPipeError: pass + + if self.link: self.link.teardown() + + def handle_git_list(self, git_stdout, for_push=False): + RNS.log("Handle git list" + (" for-push" if for_push else ""), RNS.LOG_DEBUG) + request_data = {self.IDX_REPOSITORY: self.repo_path, "for_push": for_push} + response, metadata = self.send_request(self.PATH_LIST, request_data) + + if not response or not isinstance(response, bytes): self.abort("Invalid list response from server") + + status_byte = response[0] + payload = response[1:] + + if status_byte != 0: self.abort(f"Server refused list: {payload.decode('utf-8', errors='ignore')}") + + git_stdout.write(payload.decode("utf-8")) + git_stdout.write("\n") # Required to terminate list + git_stdout.flush() + + def escape_for_stdout(self, value): + if isinstance(value, bytes): value = value.decode('utf-8', errors='replace') + + escaped = '"' + for char in value: + if char == '\\': escaped += '\\\\' + elif char == '"': escaped += '\\"' + elif char == '\n': escaped += '\\n' + elif char == '\t': escaped += '\\t' + elif char == '\r': escaped += '\\r' + elif ord(char) < 32 or ord(char) > 126: escaped += f'\\x{ord(char):02x}' + else: escaped += char + + return escaped + '"' + + def process_fetch_queue(self, fetch_queue, git_stdout, progress_enabled=False, ref_batch_size=REF_BATCH_SIZE): + import tempfile + import subprocess + + if not fetch_queue: return + + while fetch_queue: + batch = fetch_queue[:ref_batch_size] + fetch_queue = fetch_queue[ref_batch_size:] + + refs_list = [{"sha": sha, "ref": ref} for sha, ref in batch] + ref_names = [ref for _, ref in batch] + RNS.log(f"Fetching batch of {len(refs_list)} refs: {ref_names}", RNS.LOG_DEBUG) + + request_data = { self.IDX_REPOSITORY: self.repo_path, "refs": refs_list } + response, metadata = self.send_request(self.PATH_FETCH, request_data) + + if not response: self.abort(f"No data in fetch response for batch") + if not metadata: + if not isinstance(response, bytes): self.abort(f"Invalid fetch response for batch") + status_byte = response[0] + payload = response[1:] + if status_byte != 0: + error_msg = payload.decode('utf-8', errors='ignore') + self.abort(f"Fetch failed for batch: {error_msg}") + + else: + RNS.log(f"metadata: {metadata}", RNS.LOG_WARNING) + if not self.IDX_RESULT_CODE in metadata: self.abort(f"No result metadata on bundle response") + status_byte = metadata[self.IDX_RESULT_CODE] + if status_byte == 0: bundle_path = response.name + else: self.abort(f"Unknown remote state for batch ref fetch") + + if progress_enabled: + size = os.stat(bundle_path).st_size + sys.stderr.write(f"Transferring: 100% ({RNS.prettysize(size)}). \n") + sys.stderr.flush() + + stderr_arg = sys.stderr if progress_enabled else subprocess.DEVNULL + + verify_cmd = ["git", "bundle", "verify", "-q", bundle_path] + verify_result = subprocess.run(verify_cmd, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL) + + if verify_result.returncode != 0: self.abort(f"Bundle verification failed for batch") + + unbundle_cmd = ["git", "bundle", "unbundle"] + if progress_enabled: unbundle_cmd.append("--progress") + unbundle_cmd.append(bundle_path) + + unbundle_result = subprocess.run(unbundle_cmd, stderr=stderr_arg, stdout=subprocess.DEVNULL) + + if unbundle_result.returncode != 0: self.abort(f"Bundle unbundle failed for batch: Non-zero return code") + + def process_push_queue(self, push_queue, git_stdout, git_stderr, progress_enabled=False): + import tempfile + import subprocess + + for local_ref, remote_ref in push_queue: + RNS.log(f"Pushing {local_ref} to {remote_ref}", RNS.LOG_DEBUG) + + # Handle potential deletions + if not local_ref or local_ref == "": + request_data = { self.IDX_REPOSITORY: self.repo_path, "ref": remote_ref } + response, metadata = self.send_request(self.PATH_DELETE, request_data) + + if not response or not isinstance(response, bytes): + git_stdout.write(f"error {remote_ref} {self.escape_for_stdout('No response from server')}\n") + git_stdout.flush() + continue + + status_byte = response[0] + if status_byte != 0: + error_msg = response[1:].decode('utf-8', errors='ignore') + git_stdout.write(f"error {remote_ref} {self.escape_for_stdout(error_msg)}\n") + git_stdout.flush() + continue + + git_stdout.write(f"ok {remote_ref}\n") + git_stdout.flush() + continue + + force = local_ref.startswith("+") + if force: local_ref = local_ref[1:] + + stderr_arg = sys.stderr if progress_enabled else subprocess.DEVNULL + + with tempfile.TemporaryDirectory() as tmpdir: + bundle_path = tmpdir + "/push.bundle" + + create_cmd = ["git", "bundle", "create", bundle_path, local_ref] + if progress_enabled: create_cmd.insert(3, "--progress") + + create_result = subprocess.run(create_cmd, stderr=stderr_arg, stdout=subprocess.DEVNULL) + + if create_result.returncode != 0: + error_msg = "Bundle creation failed" + git_stdout.write(f"error {remote_ref} {self.escape_for_stdout(error_msg)}\n") + git_stdout.flush() + continue + + with open(bundle_path, "rb") as f: bundle_data = f.read() + + request_data = { self.IDX_REPOSITORY: self.repo_path, "local_ref": local_ref, "remote_ref": remote_ref, + "force": force, "bundle": bundle_data } + + response, metadata = self.send_request(self.PATH_PUSH, request_data) + + if not response or not isinstance(response, bytes): + git_stdout.write(f"error {remote_ref} {self.escape_for_stdout('No response from server')}\n") + git_stdout.flush() + continue + + status_byte = response[0] + if status_byte != 0: + error_msg = response[1:].decode('utf-8', errors='ignore') + git_stdout.write(f"error {remote_ref} {self.escape_for_stdout(error_msg)}\n") + git_stdout.flush() + continue + + git_stdout.write(f"ok {remote_ref}\n") + git_stdout.flush() + + +__default_rngit_config__ = '''# This is the default rngit client config file. + +[client] + +# You can control the batch size of ref transfers +# using the ref_batch_size directive: + +ref_batch_size = 25 + +[logging] +# Valid log levels are 0 through 7: +# 0: Log only critical information +# 1: Log errors and lower log levels +# 2: Log warnings and lower log levels +# 3: Log notices and lower log levels +# 4: Log info and lower (this is the default) +# 5: Verbose logging +# 6: Debug logging +# 7: Extreme logging + +loglevel = 4 + +'''.splitlines() + +if __name__ == "__main__": main() \ No newline at end of file diff --git a/RNS/Utilities/rngit/main.py b/RNS/Utilities/rngit/main.py new file mode 100644 index 0000000..7c5f270 --- /dev/null +++ b/RNS/Utilities/rngit/main.py @@ -0,0 +1,40 @@ +# Reticulum License +# +# Copyright (c) 2016-2026 Mark Qvist +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# - The Software shall not be used in any kind of system which includes amongst +# its functions the ability to purposefully do harm to human beings. +# +# - The Software shall not be used, directly or indirectly, in the creation of +# an artificial intelligence, machine learning or language model training +# dataset, including but not limited to any use that contributes to the +# training or development of such a model or algorithm. +# +# - The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import sys +from RNS.Utilities.rngit import client, server + +if __name__ == "__main__": + cmd = sys.argv[0] + if cmd == "rngit": ec = server.main() + elif cmd == "git-remote-rns": ec = client.main() + else: raise NotImplementedError(f"The {cmd} executable entrypoint is not yet implemented in rngit") + + sys.exit(ec) \ No newline at end of file diff --git a/RNS/Utilities/rngit/server.py b/RNS/Utilities/rngit/server.py new file mode 100644 index 0000000..a4cd311 --- /dev/null +++ b/RNS/Utilities/rngit/server.py @@ -0,0 +1,677 @@ +# Reticulum License +# +# Copyright (c) 2016-2026 Mark Qvist +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# - The Software shall not be used in any kind of system which includes amongst +# its functions the ability to purposefully do harm to human beings. +# +# - The Software shall not be used, directly or indirectly, in the creation of +# an artificial intelligence, machine learning or language model training +# dataset, including but not limited to any use that contributes to the +# training or development of such a model or algorithm. +# +# - The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import RNS +import os +import time +import argparse +import threading +import subprocess + +from threading import Lock +from tempfile import TemporaryDirectory + +from RNS._version import __version__ +from RNS.Utilities.rngit import APP_NAME +from RNS.vendor.configobj import ConfigObj + +def program_setup(configdir, rnsconfigdir=None, verbosity=0, quietness=0, service=False, interactive=False): + targetverbosity = verbosity-quietness + + if service: + targetlogdest = RNS.LOG_FILE + targetverbosity = None + else: + targetlogdest = RNS.LOG_STDOUT + + reticulum = RNS.Reticulum(configdir=rnsconfigdir, verbosity=targetverbosity, logdest=targetlogdest) + + RNS.log("Starting Reticulum Git Node...", RNS.LOG_NOTICE) + git_node = ReticulumGitNode(configdir=configdir, verbosity=targetverbosity) + if not git_node.ready: exit(255) + else: git_node.start() + + if interactive: + import code + code.interact(local=globals()) + + else: + while git_node._should_run: time.sleep(1) + + exit(0) + +def main(): + try: + parser = argparse.ArgumentParser(description="Reticulum Git Repository Server") + parser.add_argument("--config", action="store", default=None, help="path to alternative config directory", type=str) + parser.add_argument("--rnsconfig", action="store", default=None, help="path to alternative Reticulum config directory", type=str) + parser.add_argument('-s', '--service', action='store_true', default=False, help="rngit is running as a service and should log to file") + parser.add_argument('-i', '--interactive', action='store_true', default=False, help="drop into interactive shell after initialisation") + parser.add_argument('-v', '--verbose', action='count', default=0) + parser.add_argument('-q', '--quiet', action='count', default=0) + parser.add_argument("--version", action="version", version="rngit {version}".format(version=__version__)) + + args = parser.parse_args() + + if args.config: configarg = args.config + else: configarg = None + + if args.rnsconfig: rnsconfigarg = args.rnsconfig + else: rnsconfigarg = None + + program_setup(configdir = configarg, rnsconfigdir=rnsconfigarg, service=args.service, verbosity=args.verbose, + quietness=args.quiet, interactive=args.interactive) + + except KeyboardInterrupt: + print("") + exit() + +class ReticulumGitNode(): + JOBS_INTERVAL = 5 + + PERM_READ = 0x01 + PERM_WRITE = 0x02 + PERM_READWRITE = 0x03 + PERM_R_SMPHR = ["r", "read"] + PERM_W_SMPHR = ["w", "write"] + PERM_RW_SMPHR = ["f", "full", "rw", "readwrite"] + + TGT_NONE = 0x01 + TGT_ALL = 0x02 + TGT_NONE_SMPHR = ["n", "none", "nobody"] + TGT_ALL_SMPHR = ["a", "all", "everyone"] + + PATH_LIST = "/git/list" + PATH_FETCH = "/git/fetch" + PATH_PUSH = "/git/push" + PATH_DELETE = "/git/delete" + + RES_OK = 0x00 + RES_DISALLOWED = 0x01 + RES_INVALID_REQ = 0x02 + RES_NOT_FOUND = 0x03 + RES_REMOTE_FAIL = 0xFF + + IDX_REPOSITORY = 0x00 + IDX_RESULT_CODE = 0x01 + + def __init__(self, configdir=None, verbosity=None): + self.identity = None + self.userdir = os.path.expanduser("~") + self.global_allow = RNS.Destination.ALLOW_ALL + self.groups = {} + self.active_links = {} + self.last_announce = 0 + self.announce_interval = 0 + self.link_clean_interval = 5 + self.last_link_clean = 0 + self.active_links_lock = Lock() + self.node_name = "Anonymous Git Node" + + self.config = None + self.verbosity = verbosity or 0 + self.ready = False + self._should_run = False + + if not self.__ensure_git(): RNS.log("The \"git\" command is not available. Aborting server startup.", RNS.LOG_ERROR) + else: + if configdir != None: self.configdir = configdir + else: + if os.path.isdir("/etc/rngit") and os.path.isfile("/etc/rngit/config"): + self.configdir = "/etc/rngit" + elif os.path.isdir(self.userdir+"/.config/rngit") and os.path.isfile(self.userdir+"/.config/rngit/config"): + self.configdir = self.userdir+"/.rngit/reticulum" + else: + self.configdir = self.userdir+"/.rngit" + + RNS.logfile = self.configdir+"/server_log" + self.configpath = self.configdir+"/config" + self.identitypath = self.configdir+"/repositories_identity" + + if os.path.isfile(self.configpath): + try: self.config = ConfigObj(self.configpath) + except Exception as e: + RNS.log("Could not parse the configuration at "+self.configpath, RNS.LOG_ERROR) + RNS.log("Check your configuration file for errors!", RNS.LOG_ERROR) + RNS.panic() + else: + RNS.log("Could not load config file, creating default configuration file...") + self.__create_default_config() + RNS.log("Default config file created. Make any necessary changes in "+self.configdir+"/config and restart rngit.") + RNS.log("Exiting now") + return + + self.__apply_config() + + self.destination = RNS.Destination(self.identity, RNS.Destination.IN, RNS.Destination.SINGLE, APP_NAME, "repositories") + self.destination.set_link_established_callback(self.remote_connected) + self.register_request_handlers() + RNS.log(f"Reticulum Git Node listening on {RNS.prettyhexrep(self.destination.hash)}", RNS.LOG_NOTICE) + self.ready = True + + def __create_default_config(self): + self.config = ConfigObj(__default_rngit_config__) + self.config.filename = self.configpath + + if not os.path.isdir(self.configdir): os.makedirs(self.configdir) + self.config.write() + + def __apply_config(self): + if not os.path.isfile(self.identitypath): + identity = RNS.Identity() + identity.to_file(self.identitypath) + RNS.log(f"Repositories identity generated and persisted to {self.identitypath}", RNS.LOG_VERBOSE) + + else: + identity = RNS.Identity.from_file(self.identitypath) + RNS.log(f"Repositories identity loaded from {self.identitypath}", RNS.LOG_VERBOSE) + + if not identity: + RNS.log(f"Could not initialize repositories identity. Exiting now.", RNS.LOG_ERROR) + RNS.panic() + + else: self.identity = identity + + if "rngit" in self.config: + section = self.config["rngit"] + if "node_name" in section: self.node_name = section["node_name"] + if "announce_interval" in section: self.announce_interval = section.as_int("announce_interval")*60 + + if "logging" in self.config: + section = self.config["logging"] + if "loglevel" in section: RNS.loglevel = max(RNS.LOG_NONE, min(RNS.LOG_EXTREME, section.as_int("loglevel")+self.verbosity)) + + if "repositories" in self.config: + section = self.config["repositories"] + for group_name in section: + RNS.log(f"Loading repositery group \"{group_name}\"", RNS.LOG_VERBOSE) + group_path = os.path.expanduser(section[group_name]) + if not os.path.isdir(group_path): RNS.log(f"The path \"{group_path}\" specified for repository group \"{group_name}\" does not exist, skipping.", RNS.LOG_ERROR) + else: + self.load_repository_group(group_name, group_path) + + if "access" in self.config: + section = self.config["access"] + for group_name in section: + if group_name in self.groups: + group_permissions = section.as_list(group_name) + for entry in group_permissions: + perm, target = self.parse_permission(entry) + if not perm or not target: continue + else: + read = False; write = False + if perm == self.PERM_READ or perm == self.PERM_READWRITE: read = True + if perm == self.PERM_WRITE or perm == self.PERM_READWRITE: write = True + + if read and not target in self.groups[group_name]["read"]: self.groups[group_name]["read"].append(target) + if write and not target in self.groups[group_name]["write"]: self.groups[group_name]["write"].append(target) + + def parse_permission(self, permission_string): + comps = permission_string.split(":") + if not len(comps) == 2: return None, None + else: + perm = comps[0].lower(); target = comps[1] + if perm in self.PERM_R_SMPHR: perm = self.PERM_READ + elif perm in self.PERM_W_SMPHR: perm = self.PERM_WRITE + elif perm in self.PERM_RW_SMPHR: perm = self.PERM_READWRITE + else: perm = None + + if target in self.TGT_NONE_SMPHR: target = self.TGT_NONE + elif target in self.TGT_ALL_SMPHR: target = self.TGT_ALL + elif len(target) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2: + try: target = bytes.fromhex(target) + except Exception as e: + RNS.log(f"Invalid identity hash \"{target}\" in access permissions: {e}", RNS.LOG_ERROR) + target = None + else: target = None + + return perm, target + + def parse_request_repository_path(self, path): + components = path.split("/") + if not len(components) == 2: return None, None + else: + limit = 256 + group = components[0] + repository_name = components[1] + if len(group) > limit or len(repository_name) > limit: return None, None + else: return group, repository_name + + def resolve_permission(self, remote_identity, group_name, repository_name, permission): + remote_hash = remote_identity.hash + RNS.log(f"Resolving {group_name}/{repository_name} permission {permission} for {RNS.prettyhexrep(remote_hash)}", RNS.LOG_DEBUG) # TODO: Remove + if not group_name in self.groups: return False + if not repository_name in self.groups[group_name]["repositories"]: return False + else: + if permission == self.PERM_READ: + repository_permissions = self.groups[group_name]["repositories"][repository_name]["read"] + group_permissions = self.groups[group_name]["read"] + + elif permission == self.PERM_WRITE: + repository_permissions = self.groups[group_name]["repositories"][repository_name]["write"] + group_permissions = self.groups[group_name]["write"] + + else: return False + + if self.TGT_NONE in repository_permissions: return False + elif self.TGT_ALL in repository_permissions: return True + elif remote_hash in repository_permissions: return True + else: + if self.TGT_NONE in group_permissions: return False + elif self.TGT_ALL in group_permissions: return True + elif remote_hash in group_permissions: return True + else: return False + + return False + + return False + + def load_repository_group(self, group_name, group_path): + if not group_name in self.groups: self.groups[group_name] = { "path": group_path, "repositories": {}, "read": [], "write": [] } + if group_name in self.groups and self.groups[group_name]["path"] != group_path: + RNS.log(f"Repository group path did not match existing entry while loading {group_name}, aborting load", RNS.LOG_ERROR) + return + + loaded = 0 + group = self.groups[group_name] + for entry in os.listdir(group_path): + path = f"{group_path}/{entry}" + if os.path.isdir(path): + if not self.__is_git_repository(path): RNS.log(f"The directory \"{path}\" is not a git repository, skipping", RNS.LOG_WARNING) + else: + if not self.__is_bare_repository(path): + RNS.log(f"The directory \"{path}\" is not a bare git repository, skipping", RNS.LOG_WARNING) + RNS.log(f"You can change it to a bare repository using \"git config --bool core.bare true\".", RNS.LOG_WARNING) + + else: + repository_name = os.path.basename(path) + allowed_path = f"{path}.allowed" + read_allowed = [] + write_allowed = [] + + if os.path.isfile(allowed_path): + if os.access(allowed_path, os.X_OK): + allowed_result = subprocess.run([allowed_path], stdout=subprocess.PIPE) + allowed_input = allowed_result.stdout.decode("utf-8") + + else: + fh = open(allowed_path, "rb") + allowed_input = fh.read().decode("utf-8") + fh.close() + + for entry in allowed_input.splitlines(): + perm_input = entry.strip() + if not perm_input.startswith("#"): + perm, target = self.parse_permission(perm_input) + if not perm or not target: continue + else: + read = False; write = False + if perm == self.PERM_READ or perm == self.PERM_READWRITE: read = True + if perm == self.PERM_WRITE or perm == self.PERM_READWRITE: write = True + + if read and not target in read_allowed: read_allowed.append(target) + if write and not target in write_allowed: write_allowed.append(target) + + group["repositories"][repository_name] = {"name": repository_name, "group": group_name, "path": path, "read": read_allowed, "write": write_allowed } + loaded += 1 + + ms = "y" if loaded == 1 else "ies" + RNS.log(f"Loaded {loaded} repositor{ms} for group \"{group_name}\"", RNS.LOG_VERBOSE) + + def start(self): + self._should_run = True + threading.Thread(target=self.jobs, daemon=True).start() + + def announce(self): + RNS.log("Announcing repositories destination", RNS.LOG_VERBOSE) + self.destination.announce() + self.last_announce = time.time() + + def jobs(self): + while self._should_run: + time.sleep(self.JOBS_INTERVAL) + try: + if self.announce_interval and time.time() > self.last_announce + self.announce_interval: self.announce() + if time.time() > self.last_link_clean + self.link_clean_interval: + stale_links = [] + for link_id in self.active_links: + link = self.active_links[link_id] + if not link.status == RNS.Link.ACTIVE: stale_links.append(link_id) + + cleaned_links = 0 + for link_id in stale_links: + link = None + with self.active_links_lock: + if link_id in stale_links: + link = self.active_links.pop(link_id) + cleaned_links += 1 + + if link and hasattr(link, "temporary_directories"): + for tmpdir in link.temporary_directories: + try: + tmpdir.cleanup() + RNS.log(f"Cleaned up {tmpdir.name}", RNS.LOG_DEBUG) + + except Exception as e: + RNS.log(f"Error while cleaning temporary directory: {e}", RNS.LOG_ERROR) + + self.last_link_clean = time.time() + if cleaned_links > 0: RNS.log(f"Cleaned {cleaned_links} links", RNS.LOG_DEBUG) + + except Exception as e: RNS.log(f"Error while running periodic jobs: {e}", RNS.LOG_ERROR) + + def __ensure_git(self): + try: subprocess.run(["git", "--version"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); return True + except: return False + + def __is_git_repository(self, path): + try: + result = subprocess.run(["git", "rev-parse", "--git-dir"], cwd=path, check=True, capture_output=True, text=True) + if not result: return False + else: check = result.stdout.strip() + if check == ".": return True + else: return False + + except: return False + + def __is_bare_repository(self, path): + try: + result = subprocess.run(["git", "config", "--bool", "core.bare"], cwd=path, check=True, capture_output=True, text=True) + if not result: return False + else: check = result.stdout.strip() + if check == "true": return True + else: return False + + except: return False + + def register_request_handlers(self): + ga_list = self.global_allowed_list if self.global_allow == RNS.Destination.ALLOW_LIST else None + self.destination.register_request_handler(self.PATH_LIST, self.handle_list, allow=self.global_allow, allowed_list=ga_list) + self.destination.register_request_handler(self.PATH_FETCH, self.handle_fetch, allow=self.global_allow, allowed_list=ga_list) + self.destination.register_request_handler(self.PATH_PUSH, self.handle_push, allow=self.global_allow, allowed_list=ga_list) + self.destination.register_request_handler(self.PATH_DELETE, self.handle_list, allow=self.global_allow, allowed_list=ga_list) + + def remote_connected(self, link): + RNS.log(f"Peer connected to {self.destination}", RNS.LOG_DEBUG) + link.set_remote_identified_callback(self.remote_identified) + link.set_link_closed_callback(self.remote_disconnected) + + def remote_disconnected(self, link): + RNS.log(f"Peer disconnected from {self.destination}", RNS.LOG_DEBUG) + + def remote_identified(self, link, identity): + self.active_links[link.link_id] = link + RNS.log(f"Peer identified as {link.get_remote_identity()} on {link}", RNS.LOG_DEBUG) + + def handle_list(self, path, data, request_id, remote_identity, requested_at): + RNS.log(f"List request from remote {remote_identity}", RNS.LOG_DEBUG) + if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified" + if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request" + if not self.IDX_REPOSITORY in data: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No repository specified" + + group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY]) + + # Check for_push permission if requested + for_push = data.get("for_push", False) + if for_push: access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_WRITE) + else: access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ) + + if not access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found" + else: + repository_path = self.groups[group_name]["repositories"][repository_name]["path"] + + try: + RNS.log(f"Listing refs for {group_name}/{repository_name}", RNS.LOG_DEBUG) + + # Get HEAD symref + head_path = os.path.join(repository_path, "HEAD") + head_ref = "master" # Use "master" as default + if os.path.exists(head_path): + with open(head_path, "rb") as fh: + head_content = fh.read() + if head_content.startswith(b"ref: "): head_ref = head_content[5:].strip().decode("utf-8", errors="ignore") + + execv = ["git", "for-each-ref", "--format", "%(objectname) %(refname)"] + result = subprocess.run(execv, cwd=repository_path, capture_output=True, check=False, text=True) + + if result.returncode != 0: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + result.stderr.encode("utf-8") + + # Build response in format: refs + @ HEAD + response_lines = result.stdout.strip() + + # Deduplicate refs - TODO: Re-evaluate this + seen_refs = set() + unique_lines = [] + for line in response_lines.split('\n'): + if not line.strip(): continue + parts = line.split(' ', 1) + if len(parts) == 2: + ref_name = parts[1] + if ref_name not in seen_refs: + seen_refs.add(ref_name) + unique_lines.append(line) + + if unique_lines: output = '\n'.join(unique_lines) + f"\n@{head_ref} HEAD\n" + else: output = f"@{head_ref} HEAD\n" + + return b"\x00" + output.encode("utf-8") + + except Exception as e: + RNS.log(f"Error while handling list request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR) + return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8") + + def handle_fetch(self, path, data, request_id, link_id, remote_identity, requested_at): + RNS.log(f"Fetch request from remote {remote_identity}", RNS.LOG_DEBUG) + with self.active_links_lock: + if not link_id in self.active_links: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified" + else: link = self.active_links[link_id] + + if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified" + if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request" + if not self.IDX_REPOSITORY in data: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No repository specified" + + group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY]) + read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ) + + if not read_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found" + else: + repository_path = self.groups[group_name]["repositories"][repository_name]["path"] + refs = data.get("refs", []) + + if not refs: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No refs specified" + + try: + ref_names = [r["ref"] for r in refs] + RNS.log(f"Fetching refs {ref_names} for {group_name}/{repository_name}", RNS.LOG_DEBUG) + + if not hasattr(link, "temporary_directories"): link.temporary_directories = [] + tmpdir = TemporaryDirectory() + link.temporary_directories.append(tmpdir) + tmp_path = tmpdir.name + RNS.log(f"Created {tmp_path} for {link}", RNS.LOG_DEBUG) + + bundle_path = os.path.join(tmp_path, "fetch.bundle") + execv = ["git", "bundle", "create", "--no-progress", bundle_path] + ref_names + result = subprocess.run(execv, cwd=repository_path, capture_output=True, check=False) + if result.returncode != 0: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + result.stderr + + return open(bundle_path, "rb"), {self.IDX_RESULT_CODE: self.RES_OK} + + except Exception as e: + RNS.log(f"Error while handling fetch request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR) + return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8") + + def handle_push(self, path, data, request_id, remote_identity, requested_at): + RNS.log(f"Push request from remote {remote_identity}", RNS.LOG_DEBUG) + if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified" + if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request" + if not self.IDX_REPOSITORY in data: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No repository specified" + + group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY]) + read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ) + write_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_WRITE) + + if not write_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found" if not read_access else self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed" + else: + repository_path = self.groups[group_name]["repositories"][repository_name]["path"] + local_ref = data.get("local_ref", "") + remote_ref = data.get("remote_ref", "") + force = data.get("force", False) + bundle_data = data.get("bundle", b"") + + if not local_ref or not remote_ref: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Missing ref specification" + try: + RNS.log(f"Push {local_ref}:{remote_ref} to {group_name}/{repository_name}", RNS.LOG_DEBUG) + + with TemporaryDirectory() as tmpdir: + bundle_path = os.path.join(tmpdir, "push.bundle") + + if isinstance(bundle_data, str): bundle_data = bundle_data.encode("utf-8") + with open(bundle_path, "wb") as f: f.write(bundle_data) + + execv = ["git", "bundle", "verify", bundle_path] + result = subprocess.run(execv, cwd=repository_path, capture_output=True, check=False) + + if result.returncode != 0: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + result.stderr + + execv = ["git", "fetch", bundle_path, f"{local_ref}:{remote_ref}"] + if force: execv.append("--force") + + result = subprocess.run(execv, cwd=repository_path, capture_output=True, check=False) + + if result.returncode != 0: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + result.stderr + + return b"\x00" + + except Exception as e: + RNS.log(f"Error while handling push request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR) + return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8") + + def handle_delete(self, path, data, request_id, remote_identity, requested_at): + RNS.log(f"Delete request from remote {remote_identity}", RNS.LOG_DEBUG) + if not remote_identity: return self.RES_DISALLOWED.to_bytes(1, "big") + b"Not identified" + if not type(data) == dict: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request" + if not self.IDX_REPOSITORY in data: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No repository specified" + + group_name, repository_name = self.parse_request_repository_path(data[self.IDX_REPOSITORY]) + read_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_READ) + write_access = self.resolve_permission(remote_identity, group_name, repository_name, self.PERM_WRITE) + + if not write_access: return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Not found" if not read_access else self.RES_DISALLOWED.to_bytes(1, "big") + b"Not allowed" + else: + repository_path = self.groups[group_name]["repositories"][repository_name]["path"] + ref_to_delete = data.get("ref", "") + + if not ref_to_delete.startswith("refs/"): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid ref" + try: + RNS.log(f"Deleting ref {ref_to_delete} in {group_name}/{repository_name}", RNS.LOG_DEBUG) + execv = ["git", "update-ref", "-d", ref_to_delete] + result = subprocess.run(execv, cwd=repository_path, capture_output=True, check=False) + + if result.returncode != 0: return self.RES_REMOTE_FAIL.to_bytes(1, "big") + result.stderr + else: return b"\x00" + + except Exception as e: + RNS.log(f"Error while handling delete request for {group_name}/{repository_name}: {e}", RNS.LOG_ERROR) + return self.RES_REMOTE_FAIL.to_bytes(1, "big") + str(e).encode("utf-8") + + +__default_rngit_config__ = '''# This is the default rngit config file. +# You will need to edit it to specify repository locations and +# access permissions. + +[rngit] + +# Automatic announce interval in minutes. +# 6 hours by default. + +announce_interval = 360 + +# An optional name for this node, included +# in announces. + +# node_name = Anonymous Git Node + +[repositories] + +# You can define multiple repository groups, each with a path +# to the directory containing "repo_name.git" directories. + +internal = /path/to/directory/with/git/repositories +public = /another/path/to/directory/with/git/repositories +showcase = /another/path/to/directory/with/git/repositories + + +[access] + +# You can apply permissions for all repositories within +# different repository collections like this: + +public = r:all, w:9710b86ba12c42d1d8f30f74fe509286 +internal = rw:9710b86ba12c42d1d8f30f74fe509286, r:all + +# By default, all repositories sourced from the con- +# figured repository collection paths have no permissions +# enabled, and will be neither readable nor writable. +# +# To configure permissions per repository, you must create +# an ".allowed" file matching the repository name. If the +# repository is in a folder called "my_project.git", create +# a "my_project.git.allowed" file next to it. This file must +# contain a permission statement on each line in the form of +# "r=IDENTITY_HASH", "w=IDENTITY_HASH" or "rw=IDENTITY_HASH". +# Instead of IDENTITY_HASH, you can also use "all" or "none". +# +# You can also make the allow-files executable, and have them +# evaluate or source the permissions from somewhere else, +# and then output the results to stdout. +# +# Additionally, you can create a "group.allowed" file in the +# root of a repository group directory, which will apply to +# all repositories within this group. The same syntax and +# functionality applies here. + + +[logging] +# Valid log levels are 0 through 7: +# 0: Log only critical information +# 1: Log errors and lower log levels +# 2: Log warnings and lower log levels +# 3: Log notices and lower log levels +# 4: Log info and lower (this is the default) +# 5: Verbose logging +# 6: Debug logging +# 7: Extreme logging + +loglevel = 4 + +'''.splitlines() + +if __name__ == "__main__": main() \ No newline at end of file