aboutsummaryrefslogtreecommitdiffstats
path: root/README.md
diff options
context:
space:
mode:
Diffstat (limited to 'README.md')
-rw-r--r--README.md196
1 files changed, 196 insertions, 0 deletions
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.