From 9b2328bcb46b1b5fd074fdbc2c4bea8855220276 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 23 Jun 2026 11:18:05 +0200 Subject: Initial commit: gitctl Thin CLI plus a server helper (run as the git user) to manage repos on a personal gitolite3 + cgit server over a command=-restricted SSH key. Includes the client, the helper, bash completion, a Claude Code skill, and docs. Co-Authored-By: Claude Opus 4.8 --- README.md | 196 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 README.md (limited to 'README.md') diff --git a/README.md b/README.md new file mode 100644 index 0000000..a965012 --- /dev/null +++ b/README.md @@ -0,0 +1,196 @@ +# gitctl + +Thin CLI to manage repos on a personal gitolite3 + cgit server. The client on +your laptop is a dumb caller. All privileged server edits live in one helper +(run as the git user) reached over SSH through a `command=`-restricted key. + +## Layout + +- `gitctl` client CLI (your laptop) +- `gitctl-helper` server helper (runs as the git user, behind a restricted key) +- cgit `repo.desc=` has exactly one writer: the existing `sync-cgit-descs.py`, + driven from each bare repo's `description` file. + +## SSH setup + +gitctl uses TWO separate SSH paths. They MUST stay distinct: the config +aborts if `helper_ssh_alias` equals `push_ssh_alias`. + +- `git_push` everyday, unrestricted git access (gitolite pushes), user `git` +- `git_helper` restricted helper key, locked to `gitctl-helper` by `command=`, + also the `git` user + +Both paths use the `git` user. The helper does NOT need root: the `git` user +already owns the bare repos' `description` files, and you make it own +`/etc/cgitrc` (step 2). This keeps `PermitRootLogin no` intact. + +### 1. Generate a dedicated helper keypair (on the laptop) + +``` +ssh-keygen -t ed25519 -f ~/.ssh/gitctl_helper -C gitctl-helper +``` + +Keep this separate from your everyday git key. + +### 2. Let the git user write cgitrc, and authorize the helper key (on the server) + +`/etc/cgitrc` is usually `root:root`. The helper writes it in place (no temp +file, so it does not need write access to `/etc` itself), but the `git` user +must own the file. cgit only reads it, so `644` keeps that working: + +``` +chown git:gitolite3 /etc/cgitrc +chmod 644 /etc/cgitrc +``` + +Now authorize the helper key. **Do NOT add it to the git user's normal +`~/.ssh/authorized_keys`** — gitolite owns and regenerates that file from its +keydir, so a hand-added line is wiped and, worse, an existing gitolite key may +match first and run `gitolite-shell` instead of the helper (you get +`FATAL: unknown git/gitolite command`). + +Instead give the git user a SECOND authorized_keys file that gitolite does not +manage. Put the restricted helper line (from `authorized_keys.example`, your +real pubkey) in it: + +``` +# on the server, as root or git +printf '%s\n' 'command="/usr/local/bin/gitctl-helper",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA...your gitctl_helper.pub... gitctl-helper' \ + > ~git/.ssh/gitctl_keys +chown git:gitolite3 ~git/.ssh/gitctl_keys +chmod 600 ~git/.ssh/gitctl_keys +``` + +Then tell sshd to read it for the git user. Append at the END of +`/etc/ssh/sshd_config` (Match blocks must come after the global section): + +``` +Match User git + AuthorizedKeysFile .ssh/authorized_keys .ssh/gitctl_keys +``` + +Validate and reload (keep your current session open in case of a typo): + +``` +sshd -t && systemctl reload ssh # or sshd / ssh, per distro +``` + +The `command=` forces `gitctl-helper` regardless of what the client sends; the +no-* options strip forwarding and pty. `PermitRootLogin no` stays intact. + +### 3. Add two ssh-config entries (on the laptop) + +In `~/.ssh/config`, adjust `HostName`, `Port`, and `IdentityFile` to your +server. Both use `User git`: + +``` +Host git_push # everyday unrestricted git access + HostName git.example.com + User git + Port 22 + IdentityFile ~/.ssh/git_id # your gitolite key (or a card's .pub, agent signs) + IdentitiesOnly yes + +Host git_helper # restricted helper key + HostName git.example.com + User git + Port 22 + IdentityFile ~/.ssh/gitctl_helper + IdentitiesOnly yes +``` + +`IdentitiesOnly yes` on BOTH hosts matters, for two reasons: + +- If your ssh-agent (or a hardware key) holds a key that gitolite also knows, + ssh may offer it first and the server runs gitolite-shell instead of the + helper: `FATAL: unknown git/gitolite command`. Pinning avoids it. +- A loaded agent can offer many keys; the server's `MaxAuthTries` (often 5) + cuts you off with `Too many authentication failures` before the right key is + tried. `IdentitiesOnly yes` offers only the named `IdentityFile`. + +If your gitolite key lives on a smartcard, point `IdentityFile` at the card's +**public** key file (export with `ssh-add -L | grep cardno > ~/.ssh/gitcard.pub`); +the agent still does the signing. + +Do not collapse the two: `git_push` stays unrestricted for normal pushes; +`git_helper` is locked to the helper by `command=`. The alias names here must +match `push_ssh_alias` and `helper_ssh_alias` in your config.toml. + +### 4. Verify both paths + +``` +ssh git_helper list-sections # prints your cgit sections via the helper +ssh git_push info # gitolite access check +``` + +## Server install + +1. Copy the helper, world-readable and executable (it runs as the git user): + ``` + sudo install -m 755 -o root -g root gitctl-helper /usr/local/bin/gitctl-helper + ``` +2. Confirm the constants at the top of `gitctl-helper` match your server: + `REPO_BASE`, `CGITRC`, `SYNC_SCRIPT`, `BACKUP_DIR`, `DEFAULT_OWNER`. The git + user must be able to create `BACKUP_DIR` (default + `/var/lib/gitolite3/cgitrc-backups`); it is made on first write. +3. Authorize the helper key (see SSH setup, step 2). +4. Self-test the helper: + ``` + python3 /usr/local/bin/gitctl-helper --self-test + ``` + +## Client install + +1. Copy the client: + ``` + install -m 755 gitctl ~/.local/bin/gitctl + ``` +2. Create `~/.config/gitctl/config.toml` from `config.toml.example`. +3. Set up the two ssh-config entries (see SSH setup, step 3). +4. Self-test the client: + ``` + gitctl --self-test + ``` +5. (Optional) Install bash completion: + ``` + sudo cp completions/gitctl.bash /etc/bash_completion.d/gitctl + ``` + Or source it from `~/.bashrc`. It completes subcommands and flags, and pulls + `--section` values live from the server (one ssh call, only after `--section`). +6. (Optional) If you drive gitctl through a Claude Code agent, install the + bundled skill so the agent knows when and how to use it: + ``` + cp -r skills/gitctl ~/.claude/skills/gitctl + ``` + +## Usage + +``` +gitctl sections list +gitctl sections add "Generic Projects" +gitctl repo create publisher --section SlackBuilds --desc "..." +gitctl repo desc publisher "a new description" +gitctl repo add-remote publisher --remote-name origin +gitctl sync +``` + +End-to-end agent flow: +``` +gitctl repo create publisher --section SlackBuilds --desc "..." +gitctl repo add-remote publisher --remote-name origin +git push -u origin master +``` + +## Notes + +- `repo create` shows the gitolite.conf diff and asks before committing. On + decline it reverts the conf edit, leaving a clean tree. +- `--desc` never writes cgit `repo.desc=` directly. It writes the bare repo's + `description` file and runs sync, the single writer. +- Every operation is idempotent and safe to re-run. +- The helper runs as the `git` user, not root. It writes `/etc/cgitrc` in place + (the git user owns the file but cannot create temp files in `/etc`, so the + write is not atomic). A timestamped backup (`cgitrc.bak.YYYYMMDD-HHMMSS`) is + written to `BACKUP_DIR` before every edit, so a crash mid-write is recoverable. + Backups are never pruned; delete old ones by hand if they pile up. +- The sync script (`sync-cgit-descs.py`) must be runnable as the git user. -- cgit v1.2.3