Added Reticulum Git Node utility as part of included utility programs. Added git remote helper to interact with git repositories over Reticulum.

This commit is contained in:
Mark Qvist 2026-04-25 17:53:33 +02:00
commit 6a7f081f12
4 changed files with 1309 additions and 0 deletions

View file

@ -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"))]))

View file

@ -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 <remote-name> <url>", 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://<hash>/<group>/<repo>", 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 <name> <value>
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 <sha> <ref>
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 <local_ref>:<remote_ref>
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()

View file

@ -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)

View file

@ -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 + @<ref> 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()