authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 00:19:46-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 15:36:02-07:00
logd7e04000d859a9030b8475d00cf8b411b7343956
tree825bfa749b2b0b3f38d1a145a0c56b256c587c28
parent58a05c402b8eaf144eb87dba875171880a10b09d
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

3 files changed, 24 insertions(+), 3 deletions(-)

service/shale/prepare.py+9-1
......@@ -3,11 +3,19 @@
33import json
44import os
55from pathlib import Path
6import re
67import sqlite3
78import sys
89
910data = json.load(sys.stdin)
10path = Path(data["hostRoot"]) / "data/astheno.shale.db"
11root = Path(data["hostRoot"])
12for config in (root / "repositories_owned").glob("*/config"):
13 text = config.read_text()
14 if not re.search(r"(?im)^\s*receivepack\s*=", text):
15 # Shale authorizes pushes before CGI; its own repository initializer enables this.
16 with config.open("a") as file:
17 file.write("\n[http]\n\treceivepack = true\n")
18path = root / "data/astheno.shale.db"
1119if path.exists():
1220 domain = os.environ["STUDIO_DOMAIN"]
1321 old, new = "auth." + domain + "/realms/master", "snowglobe." + domain
tools/import-forgejo-to-shale.py+1-1
......@@ -155,7 +155,7 @@ def main():
155155 for folder in ('objects', 'refs'):
156156 (directory / folder).mkdir(mode=0o700)
157157 os.chown(directory / folder, stat.st_uid, stat.st_gid)
158 (directory / 'config').write_text('[core]\nrepositoryformatversion = 0\nbare = true\n')
158 (directory / 'config').write_text('[core]\nrepositoryformatversion = 0\nbare = true\n[http]\nreceivepack = true\n')
159159 access = 'private' if row['is_private'] else 'public'
160160 cursor = db.execute(
161161 'INSERT INTO repositories(uuid,owner,created_on,name,description,' + ','.join(ACCESS) + ',last_updated) '
tools/router.py+14-1
......@@ -83,6 +83,18 @@ def shale_write_routes(host):
8383 " }", ' respond @shale_unsafe_origin "Forbidden" 403']
8484
8585
86def shale_git_routes(upstreams):
87 lines = [" @shale_git path */info/refs */git-upload-pack */git-receive-pack",
88 " handle @shale_git {", f" reverse_proxy {upstreams} {{"]
89 # Shale forwards CGI Status as a header instead of the HTTP status.
90 for status in (403, 404, 500):
91 lines += [f' @cgi_{status} header Status "{status} *"',
92 f" handle_response @cgi_{status} {{",
93 " header {", " -Status", " defer", " }",
94 f" copy_response {status}", " }"]
95 return [*lines, " }", " }"]
96
97
8698def render(token):
8799 with open(os.environ["STUDIO_PROXY_TOKEN_FILE"]) as file:
88100 dashboard_proof = file.read().strip()
......@@ -178,7 +190,8 @@ def render(token):
178190 if service == "shale" or re.fullmatch(r"shale-preview-[0-9a-f]{8}", service):
179191 # Keep the Origin guard before every static/proxy handle;
180192 # otherwise Caddy sorts those handles ahead of respond.
181 lines += [" route {", *shale_write_routes(host)]
193 lines += [" route {", *shale_write_routes(host),
194 *shale_git_routes(" ".join(sorted(route["upstreams"])))]
182195 if re.fullmatch(r"shale-preview-[0-9a-f]{8}", service):
183196 lines += shale_attachment_routes(" ".join(sorted(route["upstreams"])),
184197 "/srv/staging/" + service)