aboutsummaryrefslogtreecommitdiffstats
path: root/README.md
blob: a965012da94882b751ecd796256cb52c4662fb66 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
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.