From d7e04000d859a9030b8475d00cf8b411b7343956 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 5 Oct 2026 00:19:46 -0700 Subject: [PATCH] Fix authenticated pushes to imported Shale repositories Enable the same receive-pack setting Shale uses for its own repositories while retaining Shale's push ACL. Translate forwarded CGI errors into real HTTP errors at the Git route. Validated owner discovery and a real jj push on a disposable config clone; anonymous, invalid and non-owner discovery and writes stay denied. Assisted-by: gpt-6.1-sol --- service/shale/prepare.py | 10 +++++++++- tools/import-forgejo-to-shale.py | 2 +- tools/router.py | 15 ++++++++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/service/shale/prepare.py b/service/shale/prepare.py index 3fe3560d4de8a012a932961ea0b331062bb77ae6..897e58d82f5ca57480010c71529ea694de3a35bd 100644 --- a/service/shale/prepare.py +++ b/service/shale/prepare.py @@ -3,11 +3,19 @@ import json import os from pathlib import Path +import re import sqlite3 import sys data = json.load(sys.stdin) -path = Path(data["hostRoot"]) / "data/astheno.shale.db" +root = Path(data["hostRoot"]) +for config in (root / "repositories_owned").glob("*/config"): + text = config.read_text() + if not re.search(r"(?im)^\s*receivepack\s*=", text): + # Shale authorizes pushes before CGI; its own repository initializer enables this. + with config.open("a") as file: + file.write("\n[http]\n\treceivepack = true\n") +path = root / "data/astheno.shale.db" if path.exists(): domain = os.environ["STUDIO_DOMAIN"] old, new = "auth." + domain + "/realms/master", "snowglobe." + domain diff --git a/tools/import-forgejo-to-shale.py b/tools/import-forgejo-to-shale.py index ca1e958bfd58d998ca6fcb2066d699b9faaf32eb..d9e93fab91054b5c9d0827169793706d3ae112fb 100644 --- a/tools/import-forgejo-to-shale.py +++ b/tools/import-forgejo-to-shale.py @@ -155,7 +155,7 @@ def main(): for folder in ('objects', 'refs'): (directory / folder).mkdir(mode=0o700) os.chown(directory / folder, stat.st_uid, stat.st_gid) - (directory / 'config').write_text('[core]\nrepositoryformatversion = 0\nbare = true\n') + (directory / 'config').write_text('[core]\nrepositoryformatversion = 0\nbare = true\n[http]\nreceivepack = true\n') access = 'private' if row['is_private'] else 'public' cursor = db.execute( 'INSERT INTO repositories(uuid,owner,created_on,name,description,' + ','.join(ACCESS) + ',last_updated) ' diff --git a/tools/router.py b/tools/router.py index 58959ed2ccf1188ffc245ef59ce591367e7c0f73..b6311bd108befa91145a9dead59db0a925a187b1 100644 --- a/tools/router.py +++ b/tools/router.py @@ -83,6 +83,18 @@ def shale_write_routes(host): " }", ' respond @shale_unsafe_origin "Forbidden" 403'] +def shale_git_routes(upstreams): + lines = [" @shale_git path */info/refs */git-upload-pack */git-receive-pack", + " handle @shale_git {", f" reverse_proxy {upstreams} {{"] + # Shale forwards CGI Status as a header instead of the HTTP status. + for status in (403, 404, 500): + lines += [f' @cgi_{status} header Status "{status} *"', + f" handle_response @cgi_{status} {{", + " header {", " -Status", " defer", " }", + f" copy_response {status}", " }"] + return [*lines, " }", " }"] + + def render(token): with open(os.environ["STUDIO_PROXY_TOKEN_FILE"]) as file: dashboard_proof = file.read().strip() @@ -178,7 +190,8 @@ def render(token): if service == "shale" or re.fullmatch(r"shale-preview-[0-9a-f]{8}", service): # Keep the Origin guard before every static/proxy handle; # otherwise Caddy sorts those handles ahead of respond. - lines += [" route {", *shale_write_routes(host)] + lines += [" route {", *shale_write_routes(host), + *shale_git_routes(" ".join(sorted(route["upstreams"])))] if re.fullmatch(r"shale-preview-[0-9a-f]{8}", service): lines += shale_attachment_routes(" ".join(sorted(route["upstreams"])), "/srv/staging/" + service) -- 2.54.0