# pub-repo Een minimale git-remote voor je eigen site. Je pusht met één token, en zet per repository met één knop of hij publiek zichtbaar en anoniem te clonen is. Geen accounts, geen issues, geen pull requests, geen CI. Wel: echte `git clone` en `git push` over HTTPS, en een bladerbare weergave van je publieke code. ## Wat het doet | | | |---|---| | **Pushen** | Alleen jij, met één owner-token via HTTPS Basic auth | | **Publieke repo** | Anoniem te clonen én te bladeren op je site — als momentopname | | **Privé repo** | Onzichtbaar: geeft exact hetzelfde antwoord als een repo die niet bestaat | | **Bladeren** | Bestandsboom, bestandsinhoud met syntax highlighting, README-rendering, branchkeuze | | **Beheer** | `/admin`: repo aanmaken, beschrijving zetten, publiek/privé omzetten, verwijderen | Bewust weggelaten: issues, forks, pull requests, meerdere gebruikers — en commitgeschiedenis, zie hieronder. ## Geen geschiedenis Wat je publiceert is de **inhoud**, niet hoe je eraan gekomen bent. Een anonieme clone levert per branch één commit zonder ouders op: alle bestanden, geen verleden. Jouw eigen geschiedenis blijft wél op de server staan en haal je terug via een tweede transport dat altijd om je token vraagt. ``` git push -> .git jouw repository, volledige geschiedenis | /git-full <- jouw remote, push en pull momentopname v .published/.git losse repository, 1 commit per branch /git <- publiek, alleen lezen ``` Dat zijn **twee aparte repositories op schijf**, en dat is geen overdaad. Binnen één repository valt dit niet af te schermen: `transfer.hideRefs` en git-namespaces houden refs alleen uit de advertentie, waarna een client de objecten alsnog met `git fetch ` ophaalt. De handleiding van git zegt het zelf, in `gitnamespaces(7)`: > The fetch and push protocols are not designed to prevent one side from stealing > data from the other repository that was not intended to be shared. If you have > private data that you need to protect from a malicious peer, your best option is > to store it in another repository. Dus staat het in een andere repository. Wat er niet in zit, kan er niet uit. Omdat de momentopname geen ouders heeft, gaan bij het publiceren alleen de boom en de bestanden mee. Een push die niets aan de inhoud verandert levert geen nieuwe momentopname op, dus het gepubliceerde commit-id blijft dan stabiel. Oude momentopnames worden direct opgeruimd — ook die zijn geschiedenis. ## Hoe het werkt Repositories zijn gewone bare git-repos onder `REPO_ROOT`, als `.git`, met de gepubliceerde momentopnames in `REPO_ROOT/.published/`. Het git-protocol zelf wordt afgehandeld door `git http-backend` — het CGI-programma dat git meelevert. Deze applicatie doet daar de toegangscontrole voor, en leest voor de webweergave de objecten uit met gewone git-commando's. Welke van de twee repositories `http-backend` te zien krijgt, is simpelweg een kwestie van welke map als `GIT_PROJECT_ROOT` wordt meegegeven. Anoniem ophalen kan de repositories met geschiedenis dus niet eens zien staan. Je data zit nooit gevangen: `REPO_ROOT` is een map met bare repos die je kunt rsyncen, backuppen of ergens anders neerzetten. De enige extra informatie is per repo een `pub-repo.json` met de zichtbaarheid en beschrijving. ``` src/ index.ts server, middleware-volgorde, security headers config.ts omgevingsvariabelen auth.ts owner-token, Basic auth, sessiecookie repos.ts repos op schijf: aanmaken, metadata, lijst git.ts veilige wrappers om git-commando's routes/smart-http.ts toegangscontrole + git http-backend routes/web.ts publieke weergave routes/admin.ts inloggen en beheer views.ts HTML-rendering public/styles.css styling, licht en donker ``` ## Lokaal draaien Vereist Node 22+ (draait TypeScript rechtstreeks) en `git` in `PATH`. ```sh npm install cp .env.example .env # vul OWNER_TOKEN en SESSION_SECRET in npm run dev ``` Genereer de twee geheimen met `openssl rand -hex 32` (of `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`). Daarna: ga naar `/login`, log in met je `OWNER_TOKEN`, maak op `/admin` een repo aan en push ernaartoe. ## Uitrollen [deploy/setup.sh](deploy/setup.sh) doet de provisionering op een Debian- of Ubuntu-box met nginx en certbot: systeemgebruiker, mappen, een bare deploy-repo met post-receive hook, verse geheimen, een systemd-unit, een certificaat en de vhost. Het script is idempotent. De unit en de vhost komen uit de repo zelf, dus die zijn er pas na de eerste push. Vandaar twee rondes: ```sh export DOMAIN=git.voorbeeld.nl LE_EMAIL=jij@voorbeeld.nl # 1. gebruiker, mappen, deploy-repo, .env met verse geheimen, certificaat ssh root@server 'bash -s' < deploy/setup.sh # 2. code erheen git remote add production ssh://root@server/srv/git/pub-repo.git git push production main # 3. unit en vhost installeren, service starten ssh root@server 'bash -s' < deploy/setup.sh ``` Daarna deployt elke `git push production main` zichzelf via de post-receive hook: checkout bijwerken, `npm ci --omit=dev`, service herstarten. ### Wat waar staat | Pad | Wat | |---|---| | `/opt/pub-repo` | de code, een git-checkout van `/srv/git/pub-repo.git` | | `/opt/pub-repo/.env` | de geheimen, `chmod 600` | | `/var/lib/pub-repo/repos` | **je repositories met geschiedenis** — dit is je data | | `/var/lib/pub-repo/repos/.published` | de momentopnames; volledig afleidbaar uit het bovenstaande | | `/srv/git/pub-repo.git` | deploy-remote, staat los van je gepubliceerde repos | ### Twee valkuilen Beide zitten in het script verwerkt, maar als je het met de hand doet: **nginx.** `client_max_body_size 0` en `proxy_request_buffering off` op de git-locatie zijn niet optioneel. Een push is één grote POST; met de standaardlimiet van 1 MB mislukt elke push van enige omvang, en zonder de tweede schrijft nginx de hele pack eerst naar schijf. **safe.directory.** De bare deploy-repo is van `root`, de fetch in de hook draait als `pubrepo`. Zonder onderstaande regel faalt elke deploy met *detected dubious ownership*: ```sh sudo -u pubrepo -H git config --global --add safe.directory /srv/git/pub-repo.git ``` Het owner-token gaat als Basic auth over de lijn, dus draai dit nooit zonder TLS. ## Gebruik Maak de repo eerst aan op `/admin`, en koppel hem dan vanaf je werkmachine. Let op welke van de twee URL's je pakt — **jouw remote is `/git-full`**: ```sh git remote add origin https://git.voorbeeld.nl/git-full/mijn-project.git git push -u origin main ``` `/git` is uitsluitend om te lezen: dat is wat bezoekers clonen, en daar staat de momentopname zonder geschiedenis. Zet je je eigen remote daarop, dan haalt een `git pull` — of één klik op "sync" in je editor — die momentopname over je lokale historie heen. Daarom weigert `/git` elke push, met een foutmelding die de juiste URL noemt. Op een repo-pagina zie je, als je ingelogd bent, beide URL's met je eigen remote bovenaan. Pullen kan niet geweigerd worden — dat is een normale, geldige fetch. Gebeurt het toch, dan is er niets verloren, want de server houdt je geschiedenis: ```sh git remote -v # staat er /git/ in plaats van /git-full/? git reflog # de commit van vóór de pull opzoeken git reset --hard # of, als de reflog al opgeschoond is: git fetch https://git.voorbeeld.nl/git-full/mijn-project.git main git reset --hard FETCH_HEAD ``` Git vraagt om een gebruikersnaam en wachtwoord. De gebruikersnaam maakt niet uit (vul bijvoorbeeld `owner` in), het wachtwoord is je `OWNER_TOKEN`. Wil je niet elke keer typen, laat git het onthouden: ```sh git config --global credential.helper store # of: osxkeychain, manager ``` Zichtbaarheid omzetten doe je op `/admin` — de knop toont de huidige stand en zet hem om. Publiek gemaakte repos verschijnen direct op de voorpagina. Is je lokale kopie weg, dan haal je alles terug van je eigen remote: ```sh git clone https://git.voorbeeld.nl/git-full/mijn-project.git ``` ## Beveiliging Wat er is afgedekt, zodat je weet waar je op moet letten: - **Privé is echt privé.** Een privé-repo en een niet-bestaande repo geven hetzelfde antwoord, zowel op het web als op het git-endpoint. Er lekt dus geen lijst met projectnamen. - **Geschiedenis komt er niet uit**, ook niet per object-id, omdat ze in een andere repository staat dan wat publiek geserveerd wordt. - **De blader-UI leest alleen de gepubliceerde repository**, ook als jij zelf ingelogd bent. Je ziet dus precies wat een bezoeker ziet, en een commit-sha in de URL levert een 404 op in plaats van een pagina. - **Pushen vereist altijd het token**, ook naar publieke repos, en dat wordt gecontroleerd voordat de server kijkt of de repo bestaat. - **Geen padtraversal.** Alleen drie exacte URL-vormen bereiken `git http-backend`; repo-namen en refs gaan door een strikt patroon. - **Ruwe bestanden** worden altijd als `text/plain` of `application/octet-stream` geserveerd, met `nosniff` en een sandbox-CSP, zodat HTML of JS uit een repository niet als script op je eigen domein kan draaien. - **README's** worden gerenderd zonder ruwe HTML toe te staan. - **Sessiecookie** is HttpOnly, SameSite=Lax en ondertekend met een eigen geheim; POST-verzoeken van andere origins worden geweigerd. Eén ding om te weten: als je een typefout maakt in de naam van een *publieke* repo, vraagt git om een wachtwoord in plaats van te zeggen dat hij niet bestaat. Dat is de prijs voor het niet lekken van privé-namen. ## Back-up ```sh rsync -a root@server:/var/lib/pub-repo/repos/ ./repos-backup/ # incl. .published/ ``` Meer is er niet. Er is geen database.