# 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.