Federated via graft
0

Configure Feed

Select the types of activity you want to include in your feed.

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 49The token never belongs in `config.yaml` — only its file path does (`token_file`). The token itself lives in its own `chmod 600` file, owned by the user `graft` runs as. That much is enforced by `graft` itself (`config.Load` requires `token_file`, never accepts an inline value). Getting the token *into* that file safely is on you — a few rules: 50 51- **Scope it down.** `write:repository` + `write:issue` only — never an admin or org-wide token. A leaked scoped token can only touch the one repo it was made for. 52- **Never paste it into chat, email, or a ticket.** All three are permanently searchable logs the moment the secret lands in them, long after anyone remembers it's still live there. 53- **Prefer the peer's own hands on the keyboard.** The person who owns the Forgejo instance generates the token and writes it directly into `/etc/graft/<name>.token` themselves, over their own SSH session — it never transits through a third party (including you) at all. 54- **When a third party must relay it, use something that dies after one read** — a password manager's one-time-share feature (Bitwarden Send, 1Password Psst), or a self-hosted equivalent (`onetimesecret.com` is open source and self-hostable). Never a plain link with no expiry. 55- **Rotate it if you're ever unsure it stayed private** — regenerating a Forgejo token is free; guessing whether a six-month-old paste is still up is not. 56 57See [Built-in token exchange](#built-in-token-exchange) at the bottom — `graft` now has a one-time secret-share page for exactly 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 a peer to an existing federation 140 141Once `my-project` is mirrored between one Forgejo and one Radicle node, adding a second Forgejo instance to the same repo — a genuine federation, not just a pair — needs neither a new Radicle node nor any change to the two pairs already running. 142 143| Placeholder | Stands for | 144|---|---| 145| `git2.example.org` | the second Forgejo instance | 146| `bob` | the account on it that owns the mirrored repo | 147 148**The one thing worth understanding first**: a Radicle repository (a RID) already replicates to every Radicle node that seeds it, automatically, regardless of `graft`. Pointing a second Forgejo pair's `radicle.http_base_url` at the *same* seed you're already using doesn't create a new relationship — that seed already has the content. Nothing to do on the Radicle side at all for this case; skip straight to the config. 149 1501. On `git2.example.org`, create the repo (`auto_init: true`, same default branch) and a token, exactly as in step 2–3 above, scoped `write:repository` + `write:issue`, saved to its own file: 151 152 ```sh 153 echo "$TOKEN2" > /etc/graft/my-project-git2.token 154 chmod 600 /etc/graft/my-project-git2.token 155 chown graft:graft /etc/graft/my-project-git2.token 156 ``` 157 1582. 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): 159 160 ```yaml 161 repos: 162 - name: my-project-git1 163 series: my-project # both pairs share this 164 forgejo: 165 base_url: https://git.example.org 166 owner: alice 167 repo: my-project 168 token_file: /etc/graft/my-project.token 169 radicle: 170 rid: rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 171 http_base_url: https://seed.example.org 172 rad_home: /home/graft/.radicle 173 sync: { git: true, issues: true, patches: true } 174 175 - name: my-project-git2 176 series: my-project # joins the same federation 177 forgejo: 178 base_url: https://git2.example.org 179 owner: bob 180 repo: my-project 181 token_file: /etc/graft/my-project-git2.token 182 radicle: 183 rid: rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 # same RID — not a new one 184 http_base_url: https://seed.example.org # same seed — not a new node 185 rad_home: /home/graft/.radicle # always graft's own, never the peer's 186 explorer_url: https://explorer.example.org 187 sync: { git: true, issues: true, patches: true } 188 ``` 189 190 `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. 191 1923. 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. 193 1944. Confirm: the status dashboard now shows `git2.example.org` as a second row under `my-project`. If it joined an already-active federation, its side may show cells colored differently from a normal commit/issue/patch — "source" (this is where content first entered the federation) or "replicated" (this side holds it without `graft` ever having logged a push here itself, because Radicle's own gossip — or a Forgejo Authorized Integration — got there first). Hover one for the date, the original side, and a link. 195 196## Adding a brand-new Radicle node to the mesh 197 198Reusing an existing seed (above) needs nothing extra. Standing up a node nobody in the mesh has ever talked to is a two-step manual bootstrap — `graft`'s own Radicle node (`rad_home`) doesn't discover new peers on its own: 199 200```sh 201rad node connect <new-node-id>@<new-node-address>:8776 202rad seed rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 203``` 204 205Both matter. `rad node connect` is the first handshake — Radicle's peer discovery (`"peers": {"type": "dynamic"}` in `~/.radicle/config.json`) spreads pairs it already knows about, but a node nobody is yet connected to needs one explicit introduction. `rad seed` matters separately: a Radicle node's default seeding policy is to replicate *nothing* it isn't explicitly told to — being connected doesn't imply seeding. Skip either step and the new node stays either unreachable or silently empty. 206 207## Troubleshooting 208 209**"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. 210 211**"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. 212 213**"'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=...`. 214 215**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. 216 217## Self-service dashboard actions 218 219The dashboard (next to the federation heatmap) has two buttons and a footer link that do the manual work above through a browser instead: 220 221- **`+ Add peer`** (`/add-peer`) — join an existing series. Pick the federation from a dropdown; its Radicle side (RID, seed, explorer) is filled in automatically, never asked for — see "Adding a peer to an existing federation" above for why that's always safe. Paste the new peer's Forgejo details and token, confirm with the admin password, done. Live within one `sync_interval`, no restart. 222- **`+ New repo`** (`/new-repo`) — the "Adding a brand-new Radicle node" + fresh-repo path, as an inline onboarding page: it spells out the manual prerequisites (create the Forgejo repo, generate a scoped token, `rad init`, note the RID) and then takes a form with everything once you've done them. 223- **`Share a secret, once`** (`/share/new`, footer link) — the one-time-secret page from the list below. Anyone can create a share (it's the mechanism *for* someone who isn't the admin to safely hand a token over); only claiming requires the passcode. 224 225Both onboarding forms are gated by `admin_password` in `config.yaml` (defaults to `graft` — `graft` logs a warning at startup if it's still set to that; change it). Checked in constant time, rate-limited (5 wrong attempts per IP or 20 total across all IPs, per 15 minutes) — a wrong guess never reveals which field was wrong, and the limiter, not the password's strength, is what actually stands between a stranger and brute-forcing a short shared secret. 226 227## Built-in token exchange 228 229Two of the ideas from the token-sharing menu below are live; the rest are recorded for when they're worth it. 230 231**Built**, both reachable from the dashboard (see above): 232 233- **One-time secret share.** `POST /share/new` encrypts whatever's pasted (AES-256-GCM), keyed from a passcode *and* the share's own random id together — the id is never stored in reversible form, only its hash, so a full database dump alone can never derive the key. `GET/POST /share/<id>` claims it: right passcode reveals it once and destroys the record immediately; five wrong passcodes destroy it too, fail-closed; 15 minutes unclaimed and it's gone regardless. 234- **QR code.** `/share/<id>/qr.png` — encodes the claim link only, not the passcode (still meant to travel over a separate channel); a convenience for scanning off someone's screen on a call rather than copy-pasting a link. 235 236**Not built:** 237 238- **A wormhole-style PAKE exchange** (the protocol behind [`magic-wormhole`](https://github.com/magic-wormhole/magic-wormhole)): both admins type the same short human-readable code into a small CLI, and a key exchange derives a shared secret from the code without either the code or the token ever crossing the wire in the clear — `graft`'s own server, even if fully compromised, never sees the plaintext token at any point. The strongest guarantee here, and the most to build (a relay endpoint speaking the handshake, and both sides need shell access rather than just a browser). Deliberately skipped for now: the one-time-share page above already closes the gap it was meant to close, at a fraction of the engineering cost, and re-litigating that trade only makes sense if token handoffs become frequent enough that the share page's residual risk (a compromised link *and* a brute-forced passcode, however rate-limited) stops being acceptable. 239 240- **Skip the handoff entirely.** The most elegant fix doesn't share a secret better — it avoids minting a long-lived one a human has to move at all. If Forgejo ever exposes a delegated token-creation flow (the peer's admin clicks a link, logs into *their own* Forgejo, and 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 ever needs to touch a clipboard, a chat window, or a QR code in the first place. This is genuinely the right long-term direction — everything built above, share page included, is a workaround for a token having to exist as a string a human copies, and this is the only option that makes that string never exist. It isn't built because it depends on Forgejo's own OAuth2/application-token APIs supporting a delegated, scope-limited minting flow that a third party can drive — worth checking directly against a current Forgejo instance before investing in it, since the answer decides whether this is a small integration or a real feature request upstream.