Federated via graft
16 kB
269 lines
1# Tutorial
2
3This walks through mirroring one repository between a Forgejo instance and a Radicle node, from nothing. Replace the names below with your own:
4
5| Placeholder | Stands for |
6|---|---|
7| `git.example.org` | your Forgejo instance |
8| `seed.example.org` | your Radicle node's `radicle-httpd` address |
9| `alice` | the Forgejo account that owns the repo |
10| `my-project` | the repository |
11
12## Prerequisites
13
14- A Forgejo instance you can create repos and tokens on.
15- A machine with `git`, the `rad` CLI, and a running `radicle-node` — see [Radicle's own seeder guide](https://radicle.dev/guides/seeder) if you don't have one yet.
16- Go 1.23+, to build `graft`.
17
18## 1. Build graft
19
20```sh
21git clone https://git.example.org/alice/graft.git
22cd graft
23go build -o graft ./cmd/sync
24sudo mv graft /usr/local/bin/graft
25```
26
27## 2. Create a Forgejo token
28
29`graft` needs a token scoped to `write:repository` and `write:issue` on the repo it syncs.
30
31```sh
32curl -X POST -H "Authorization: token $ADMIN_TOKEN" \
33 -H "Content-Type: application/json" \
34 -d '{"name":"graft","scopes":["write:repository","write:issue"]}' \
35 https://git.example.org/api/v1/users/alice/tokens
36```
37
38Save the returned token somewhere `graft` can read and nowhere else can:
39
40```sh
41mkdir -p /etc/graft
42echo "$TOKEN" > /etc/graft/my-project.token
43chmod 600 /etc/graft/my-project.token
44chown graft:graft /etc/graft/my-project.token # the user graft actually runs as, not root
45```
46
47### Sharing a token safely
48
49`config.yaml` never holds the token itself, only `token_file`. `graft` requires `token_file` and rejects an inline value. Getting the token into that file is on you:
50
51- Scope it to `write:repository` + `write:issue`. Never admin or org-wide.
52- Never paste it into chat, email, or a ticket.
53- Prefer the token owner writing it directly into `/etc/graft/<name>.token` over their own SSH session — no third party involved.
54- If a third party must relay it, use a one-time-secret tool (Bitwarden Send, 1Password Psst, self-hosted `onetimesecret.com`), not a plain link.
55- Rotate it if its exposure is ever in doubt.
56
57See [Built-in token exchange](#built-in-token-exchange) — `graft` has a one-time secret-share page for this.
58
59## 3. Create the repo on Forgejo
60
61```sh
62curl -X POST -H "Authorization: token $TOKEN" \
63 -H "Content-Type: application/json" \
64 -d '{"name":"my-project","auto_init":true,"default_branch":"main"}' \
65 https://git.example.org/api/v1/user/repos
66```
67
68`auto_init` gives it one commit, so there's something to mirror.
69
70## 4. Create the repo on Radicle
71
72Radicle repos are created from an existing git checkout. Clone the one you just made and initialize it:
73
74```sh
75git clone https://git.example.org/alice/my-project.git
76cd my-project
77rad init --name my-project --default-branch main --public
78```
79
80This prints a Repository ID (RID), something like `rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5`. Write it down — the config needs it.
81
82If you have other Radicle nodes that should also carry this repo, tell them to seed it:
83
84```sh
85rad seed rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
86```
87
88## 5. Write the config
89
90```yaml
91# /etc/graft/config.yaml
92sync_interval: 5m
93state_db: /var/lib/graft/state.db
94
95repos:
96 - name: my-project
97 forgejo:
98 base_url: https://git.example.org
99 owner: alice
100 repo: my-project
101 token_file: /etc/graft/my-project.token
102 radicle:
103 rid: rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
104 http_base_url: https://seed.example.org
105 rad_home: /home/graft/.radicle
106 sync:
107 git: true
108 issues: true
109 patches: true
110```
111
112`rad_home` is the `RAD_HOME` of the identity `graft` pushes as — usually a dedicated one, created with `rad auth` under whatever user runs `graft`.
113
114## 6. Run it once
115
116```sh
117mkdir -p /var/lib/graft
118graft -config /etc/graft/config.yaml -once
119```
120
121No output means no errors. Check both sides:
122
123```sh
124curl -s https://seed.example.org/api/v1/repos/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
125```
126
127If you open an issue on the Forgejo repo and run `graft -once` again, it shows up on Radicle within that one pass.
128
129## 7. Run it as a service
130
131```sh
132sudo cp systemd/graft.service /etc/systemd/system/
133sudo systemctl daemon-reload
134sudo systemctl enable --now graft
135```
136
137It now mirrors both repos every `sync_interval`, in both directions, on its own.
138
139## Adding another repo from the browser
140
141Steps 2–5 above — create the Forgejo repo, mint a token, `rad init`, write the config entry — are also a form, once `graft` itself is already running: the `+ New repo` button next to the dashboard heatmap. Same prerequisites, listed inline on the page (create the Forgejo repo, generate a scoped token, `rad init`, note the RID), then one submission instead of four manual steps. Useful for a second, unrelated project once the first is up; not a way to bootstrap the very first one, since it needs `graft`'s own HTTP server already serving.
142
143Like every self-service form, it has an admin password field that's optional — see [Two ways to submit a self-service form](#two-ways-to-submit-a-self-service-form) for what filling it in, leaving it blank, or getting it wrong each do.
144
145## Adding a peer to an existing federation
146
147Adding a second Forgejo instance to a repo already mirrored between one Forgejo and one Radicle node needs no new Radicle node and no change to the pairs already running.
148
149**From the browser**, this is the `+ Add Forgejo peer` button (`/add-peer`): pick the federation from a dropdown, and the Radicle side (RID, seed, explorer) is filled in for you, not asked for — the manual steps below exist to show what that button does, and to cover the case where the peer's admin doesn't have dashboard access. Same optional-password rule as above.
150
151| Placeholder | Stands for |
152|---|---|
153| `git2.example.org` | the second Forgejo instance |
154| `bob` | the account on it that owns the mirrored repo |
155
156A Radicle RID replicates to every node that seeds it, independent of `graft`. Pointing a second pair's `radicle.http_base_url` at the same seed already in use doesn't create a new relationship — that seed already has the content. No Radicle-side step for this case.
157
1581. On `git2.example.org`, create the repo **without** `auto_init` (`auto_init: false` or omitted) and a token scoped `write:repository` + `write:issue`. `auto_init: true` gives the repo an unrelated first commit; `graft` sees that as a real divergence against the federation's existing history and refuses to push, logging "forgejo and rad diverged since last sync" (see Troubleshooting). An empty repo has no history to diverge from.
159
160 ```sh
161 echo "$TOKEN2" > /etc/graft/my-project-git2.token
162 chmod 600 /etc/graft/my-project-git2.token
163 chown graft:graft /etc/graft/my-project-git2.token
164 ```
165
1662. Add a second entry to `config.yaml`, under the same `series` as the first pair (defaults to the pair's own `name` if you never set one — give the *first* pair an explicit `series` too if you didn't already, so both land on the same row):
167
168 ```yaml
169 repos:
170 - name: my-project-git1
171 series: my-project # both pairs share this
172 forgejo:
173 base_url: https://git.example.org
174 owner: alice
175 repo: my-project
176 token_file: /etc/graft/my-project.token
177 radicle:
178 rid: rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
179 http_base_url: https://seed.example.org
180 rad_home: /home/graft/.radicle
181 sync: { git: true, issues: true, patches: true }
182
183 - name: my-project-git2
184 series: my-project # joins the same federation
185 forgejo:
186 base_url: https://git2.example.org
187 owner: bob
188 repo: my-project
189 token_file: /etc/graft/my-project-git2.token
190 radicle:
191 rid: rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 # same RID — not a new one
192 http_base_url: https://seed.example.org # same seed — not a new node
193 rad_home: /home/graft/.radicle # always graft's own, never the peer's
194 explorer_url: https://explorer.example.org
195 sync: { git: true, issues: true, patches: true }
196 ```
197
198 `rad_home` never changes between pairs — it's `graft`'s own local Radicle identity, the same one it pushes as for every pair it runs. Only the `forgejo` block is specific to the new peer.
199
2003. Restart: `sudo systemctl restart graft`. `config.Load` validates every field on startup — a typo surfaces immediately in the logs, not as a silent no-op.
201
2024. Confirm: the dashboard shows `git2.example.org` as a second row under `my-project`. A side with content but no direct push logged by `graft` shows colored cells instead of a normal commit/issue/patch cell — "source" (content originated here) or "replicated" (arrived via Radicle gossip or a Forgejo Authorized Integration, not a `graft`-logged push). Hover for date, origin, link.
203
204## Adding a brand-new Radicle node to the mesh
205
206Reusing an existing seed needs nothing extra. A node nobody in the mesh has talked to needs two manual steps — `graft`'s own Radicle node doesn't discover new peers on its own:
207
208**From the browser**, this is the `+ Add Radicle peer` button (`/add-radicle-peer`): pick the federation, enter the new node's ID and address, and it runs the same two commands below on `graft`'s own node. Same optional-password rule as [above](#two-ways-to-submit-a-self-service-form).
209
210```sh
211rad node connect <new-node-id>@<new-node-address>:8776
212rad seed rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
213```
214
215`rad node connect` is the first handshake — Radicle's peer discovery (`"peers": {"type": "dynamic"}` in `~/.radicle/config.json`) spreads pairs it already knows, but a never-connected node needs one explicit introduction. `rad seed` is separate: default seeding policy replicates nothing not explicitly told to. Skip either step and the node stays unreachable or empty.
216
217## Comments
218
219`graft` mirrors comments on issues and patches, both directions, from four origins:
220
221- **Forgejo → Radicle** and **Radicle → Forgejo**: native comments, polled and cross-mirrored each pass. Dedup is by content hash (`comment_seen` table); the mirrored copy is visibly prefixed `**via Forgejo:**` / `**via Radicle:**` for readers, but the actual loop guard is an invisible marker (zero-width characters, `state.MarkMirrored`/`IsMirroredComment`) appended to the body — a real comment that happens to start with the same visible text is never mistaken for one of graft's own mirrors.
222- **ActivityPub**: a Mastodon reply to one of graft's posts becomes a comment, prefixed `**via Fediverse, @user:**` (same invisible marker).
223- **AT Proto**: a Bluesky reply, same mechanism, prefixed `**via Bluesky, @handle:**`. Not verified against a live account — built from AT Proto's published lexicon (`app.bsky.notification.listNotifications`), but no Bluesky account existed to test against when this was written.
224
225Dashboard cells are colored by kind only (commit, issue, patch, comment) — never by origin. Origin shows up as a tooltip note only, on hover.
226
227## Troubleshooting
228
229**"token does not have at least one of required scope(s)"** — the Forgejo token is missing `write:issue` or `write:repository`. Regenerate it with both.
230
231**"forgejo and rad diverged since last sync"** — both sides got commits since the last pass that aren't ancestors of each other. `graft` won't guess which one wins; merge them by hand on either side and it'll pick up cleanly next pass.
232
233**"'remote-rad' is not a git command"** — `git-remote-rad` (installed alongside `rad`) isn't on `PATH` for the user running `graft`. Add its directory to the systemd unit's `Environment=PATH=...`.
234
235**A patch/PR never shows up on the other side** — check that the corresponding repo isn't still empty on the destination. The first git sync has to land before patches/issues referencing that content can be mirrored.
236
237## Self-service forms: submitting and reviewing
238
239The three buttons next to the federation heatmap — `+ Add Forgejo peer`, `+ Add Radicle peer`, `+ New repo` — are covered in context above, right next to their manual equivalents. This section covers what's common to all three, plus the two footer links:
240
241- **`Share a secret, once`** (`/share/new`, footer) — the one-time-secret page below. Creating a share needs no password; claiming needs the passcode.
242- **`Pending requests`** (`/admin/pending`, footer) — review queue, see below.
243
244### Two ways to submit a self-service form
245
246Each of the three onboarding forms has one optional field: the admin password. What happens depends on it:
247
248- **Left blank** — the submission becomes a pending request. Nothing happens yet: no sync, no `rad node connect`, nothing touches any Forgejo or Radicle instance. Anyone can submit one without ever knowing the password — this is the path for someone outside your trust circle (a contributor like Milo) proposing themselves as a peer.
249- **Filled in, correct** — goes live immediately, exactly as graft always has: the peer/repo is active within one `sync_interval` (no restart), or for a Radicle peer, `rad node connect` + `rad seed` run right away. This is still the right tool for a class or workshop where you hand the password to a trusted group who should self-serve without a review step.
250- **Filled in, wrong** — rejected outright. It never silently falls back to a pending request — that would turn the form into a way to probe the password with no lockout consequence, defeating the rate limiter below.
251
252### Reviewing pending requests
253
254`/admin/pending` is the only place the password gates anything for the request path. It's a stateless review list, no login session: entering the password once renders every pending Forgejo peer, new repo, and Radicle peer request, each with its own Approve/Reject button that re-submits the same password. Approving a Forgejo peer/repo request just flips it active (same "next sync pass" activation as the instant path); approving a Radicle peer request runs `rad node connect` + `rad seed` at that moment. Rejecting a Forgejo peer/repo request also deletes the token file it already wrote to disk, so a rejected submission leaves nothing behind.
255
256Both checks against it (the onboarding forms' optional field, and the pending-review page) use `admin_password` from `config.yaml` (default `graft`; `graft` logs a startup warning if left at that default), constant-time comparison, rate-limited: 5 wrong attempts per IP or 20 total, per 15 minutes. `/share/new`'s claim page is a separate secret entirely — the 6-digit passcode, checked by AES-GCM decryption rather than a stored comparison (see Built-in token exchange below) — with its own, independent rate limiter.
257
258## Built-in token exchange
259
260**Built**, reachable from the dashboard:
261
262- **One-time secret share.** `POST /share/new` encrypts the input with AES-256-GCM, keyed from a passcode and the share's own random id together. Only the id's hash is stored — a database dump alone can't derive the key. `GET/POST /share/<id>` claims it: right passcode reveals it once and deletes the record; 5 wrong passcodes deletes it too; 15 minutes unclaimed and it's gone.
263- **QR code.** `/share/<id>/qr.png` encodes the claim link only, not the passcode.
264
265**Not built:**
266
267- **Wormhole-style PAKE exchange** (the protocol behind [`magic-wormhole`](https://github.com/magic-wormhole/magic-wormhole)): both sides type the same short code into a CLI; the key exchange derives a shared secret without the code or the token crossing the wire in the clear. `graft`'s server never sees the plaintext token. Most to build — a relay endpoint for the handshake, shell access on both sides. Skipped: the share page above covers the same case at a fraction of the cost.
268
269- **Skip the handoff entirely.** If Forgejo exposes a delegated token-creation flow (peer admin clicks a link, logs into their own Forgejo, a token scoped to exactly `write:repository` + `write:issue` on exactly that repo is minted and handed to `graft` server-to-server), no plaintext secret touches a clipboard, a chat window, or a QR code. Depends on Forgejo's OAuth2/application-token APIs supporting delegated, scope-limited minting driven by a third party — unverified against a current Forgejo instance.