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
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
.Dd October 28, 2026
.Dt RSS-KAKAPO 1
.Os
.Sh NAME
.Nm rss-kakapo
.Nd turn Mastodon into your feed reader, for small private instances
.Sh SYNOPSIS
.Ev CONFIG Ns = Ns Ar config.jsonc
.Ev SECRETS Ns = Ns Ar secrets.jsonc
.Nm
.Sh DESCRIPTION
.Nm
is a Fediverse service that follows the RSS and Atom feeds of websites and
toots every new post.
Each feed gets an account of its own,
.Ql @ Ns Ar handle Ns @ Ns Ar host ,
whose handle is made from the site's address
(the site
.Ql https://blog.example.org/
gets
.Ql blog.example.org ) ;
following that account puts the site's new posts into the follower's
timeline.
.Pp
Feeds are requested from the
.Em birb ,
the instance's one built-in account (see
.Ic birb
in
.Sx CONFIGURATION ) :
mention it in a post that holds exactly one link, to the site or to its feed,
and it answers with the feed's account, creating it if it is new.
The site's existing posts become that account's first posts without being
broadcast to anyone; only posts that appear after that are tooted.
A request is refused, with a reply saying why, if the post holds no link or
several, if no feed is found at the link, if the feed is Mastodon's own, if
the site has opted out (see
.Ic blocked_feeds_file ) ,
or if the requester may not ask (see
.Ic restrict_requests ) .
.Pp
An account nobody follows any more is deleted, with its posts, at its next
check.
.Pp
.Nm
is a fork of RSS Parrot, which runs the public instance at
.Lk https://rss-parrot.net ;
it is meant for small, private instances rather than one big public one.
.Sx DIFFERENCES FROM RSS PARROT
lists what it changes, and why.
.Pp
.Nm
takes no arguments.
It reads its configuration from the files named by
.Ev CONFIG
and
.Ev SECRETS ,
listens for HTTP on
.Ic service_port
on all interfaces, and serves its web pages and their assets from
.Pa www/
in the working directory, which therefore has to be the directory that
holds it: in the Nix package,
.Pa share/rss-kakapo .
It is meant to sit behind a reverse proxy that terminates TLS for
.Ic host :
other servers fetch its accounts and posts by
.Ql https://
URLs built from it.
.Pp
Logs go to standard error.
.Sh CONFIGURATION
Both files are JSON, and may have comments and trailing commas (JSONC).
Unknown keys are ignored.
.Ss config.jsonc
.Bl -tag -width Ds
.It Ic host
The instance's domain, as it appears in account names and URLs, without
scheme.
.It Ic service_port
The TCP port to listen on.
.It Ic db_file
The SQLite database.
It is created, and its schema migrated, at startup.
.It Ic log_level
.Ql Debug ,
.Ql Info ,
.Ql Warn
or
.Ql Error
(the default).
.It Ic log_file
Ignored; logs always go to standard error.
.It Ic birb
The built-in account, an object:
.Bl -tag -width Ds
.It Ic user
Its user name.
.It Ic published
When it was created, in RFC 3339.
.It Ic manually_approves_follows
If true, follow requests to the birb are not accepted; feed accounts accept
every follower regardless.
.It Ic profile_pic , Ic header_pic
URLs of its avatar and header image.
.It Ic pub_key , Ic priv_key
Its RSA key pair, in PEM; the private key encrypted with
.Ic birb_privkey_passphrase .
.El
.It Ic fallback_profile_pic
URL of the avatar of a feed account whose site has none.
.It Ic blocked_feeds_file
Feeds whose sites have opted out: one feed URL per line, without scheme,
in lower case.
It is read on every feed request, so it can be edited while
.Nm
runs.
Unset, nothing is blocked.
Set, the file has to exist:
.Nm
refuses to start without it, and a feed request fails if it has gone
since, rather than let through a site that opted out.
.It Ic restrict_requests
If true, only the requesters listed in
.Ic allowed_requesters_file
may ask the birb for new feeds; everyone else gets a polite refusal.
Anyone may still follow the feeds that exist.
Default false.
.It Ic allowed_requesters_file
One
.Ql @ Ns Ar user Ns @ Ns Ar host
per line; case and a missing leading
.Ql @
do not matter, and lines starting with
.Ql #
are comments.
With
.Ic restrict_requests
on, a missing or unreadable file denies everyone.
.It Ic update_schedule
How often a feed is checked, in hours, depending on how long ago it last
had a new post, an object:
.Ic day
(within the last day),
.Ic week ,
.Ic weeks4
(within four weeks) and
.Ic older .
Each check is scheduled at that interval \(+-20%, so that checks spread out.
An interval that is not set, or not above 0, defaults to upstream's: 1, 3, 6
and 12.
.It Ic max_retry_delay_hours
The longest a feed is left alone after a failed check, whatever the backoff
or the server's
.Ql Retry-After
say, unless its normal interval is longer.
Default 24.
See
.Sx Polling .
.It Ic backdate_grace_hours
How far before the newest item seen so far a new item may be dated and still
be tooted.
Default 48; 0 is allowed.
See
.Sx Which items are tooted .
.It Ic max_new_items_per_fetch
If above 0, at most this many new items of one fetch of a feed are tooted,
the newest; the rest are remembered as seen.
Default 0, no limit.
.It Ic posts_min_count_kept , Ic posts_min_days_kept
If both are above 0, a feed's posts and toots are purged once they are
older than
.Ic posts_min_days_kept
days, keeping the newest
.Ic posts_min_count_kept
regardless.
Their status URLs then answer 404.
Default 0, keep everything.
.It Ic purge_wait_sec
Pause, in seconds, before each purge and after each account deletion, to
spread database load.
.It Ic cache_page_templates
If true, the web page templates are read once; if false, on every request,
which is what one wants while editing them.
.It Ic profile_dir , Ic profile_keep_days
If
.Ic profile_dir
is set, a dump of every goroutine is written into it once a minute, as
.Pa YYYY-MM-DD!hh-mm-ss.txt ,
and those of its dumps older than
.Ic profile_keep_days
days (default 7) are deleted; nothing else in the directory is touched.
Unset, no dumps are written.
.El
.Ss secrets.jsonc
.Bl -tag -width Ds
.It Ic birb_privkey_passphrase
Encrypts the private keys, of the birb and of every feed account, as stored.
.It Ic api_keys
Keys accepted by the
.Pa /api
routes, a list of strings.
.It Ic metrics_auth
Bearer token for
.Pa /metrics .
.El
.Sh HTTP INTERFACE
.Ss ActivityPub
.Bl -tag -width Ds -compact
.It Cm GET Pa /.well-known/webfinger
.It Cm GET Pa /u/ Ns Ar user
.It Cm GET Pa /u/ Ns Ar user Ns Pa /outbox
.It Cm GET Pa /u/ Ns Ar user Ns Pa /followers
.It Cm GET Pa /u/ Ns Ar user Ns Pa /following
.It Cm GET Pa /u/ Ns Ar user Ns Pa /status/ Ns Ar id
.It Cm POST Pa /u/ Ns Ar user Ns Pa /inbox , Cm POST Pa /inbox
.El
.Pp
Incoming activities must carry a valid HTTP signature.
Follows and unfollows are handled for every account, and posts that mention
the birb as feed requests; other activities are ignored.
.Ss Web pages
.Bl -tag -width Ds -compact
.It Cm GET Pa /
.It Cm GET Pa /web/feeds
.It Cm GET Pa /web/feeds/ Ns Ar handle
.It Cm GET Pa /web/changes
.It Cm GET Pa /web/about
.El
.Pp
Anything else is served from
.Pa www/ ,
or answered with the 404 page.
.Ss Administration
The
.Pa /api
routes require an
.Ql X-API-KEY
header holding one of the
.Ic api_keys .
.Bl -tag -width Ds
.It Cm POST Pa /api/feeds
Follow a feed as if it had been requested from the birb.
The body is a JSON object with a
.Ql site_url ;
the answer is the feed's account, with status 201 if it is new and 200 if it
existed.
.It Cm DELETE Pa /api/accounts/ Ns Ar handle
Delete a feed account, with everything it holds.
.It Cm POST Pa /api/actions/vacuum
.Ql VACUUM
the database.
.It Cm GET Pa /metrics
Prometheus metrics, with
.Ql Authorization: Bearer Ar metrics_auth .
.El
.Sh DIFFERENCES FROM RSS PARROT
.Bl -bullet
.It
Who may request feeds can be restricted, with
.Ic restrict_requests .
.It
Feeds are polled politely, and a server cannot silence a feed for long; see
.Sx Polling .
.It
A feed that misbehaves cannot flood timelines, and posts dated into the
future are tooted when their date comes; see
.Sx Which items are tooted .
.It
SQLite is modernc.org/sqlite, SQLite translated to Go, instead of a cgo
binding: the binary is statically
linked, and building it needs only Go.
.It
Logs always go to standard error, through a small logger on the standard
library instead of
.Ql charmbracelet/log ,
which drops eleven modules from the dependency tree.
.It
No rss-parrot.net branding in the web pages and the birb's texts: they show
the instance's own host, and there are no analytics.
.It
.Pa package.nix
builds the server and its
.Pa www/
tree with any nixpkgs
.Pq Ql callPackage ;
.Pa flake.nix
wraps it.
.El
.Ss Polling
A check sends the
.Ql ETag
and
.Ql Last-Modified
of the last successful response back, as
.Ql If-None-Match
and
.Ql If-Modified-Since ,
so an unchanged feed costs a 304 without a body.
Upstream downloads every feed in full on every check.
.Pp
Each consecutive failed check doubles the wait before the next one, up to 64
times the normal interval, and a
.Ql Retry-After
from the server, as seconds or as a date, is honoured if it asks for longer.
Neither may silence a feed for long, though: the wait after a failure is
capped at
.Ic max_retry_delay_hours ,
so that
.Ql Retry-After: 3024000
(five weeks) still means
.Dq try again tomorrow .
A feed whose normal interval is longer than the cap just stays on its normal
schedule, so a failing feed is never polled more often than a healthy one.
.Ss Which items are tooted
Upstream decides what is new by date alone: an item is new if it is dated
after the newest item seen so far, and a hash of its guid and link only weeds
out duplicates among those.
It forgets posts when it purges them, so a feed that re-dates its archive gets
it tooted all over again.
It also stores an item only once it is to be tooted, so one dated more than
two days ahead was dropped, and, as the feed then answered every check with a
304, never looked at again; and one dated less far ahead moved the newest date
seen past the posts published before it, which were lost.
.Pp
A feed that goes wrong can flood its followers' timelines, so
.Nm
errs on the side of tooting too little.
It remembers every item it has ever seen, by its guid and by its link, and
never forgets one while the feed's account exists; purging drops the posts'
contents, not the memory of them.
Links are compared without scheme, with the host in lower case, without a
trailing slash and without
.Ql utm_*
parameters; a link that several items of a feed share identifies none of
them.
An item is tooted only if both
.Bl -bullet
.It
it is new: neither its guid nor its link was ever seen for the feed;
.It
and it is recent: dated after the newest item seen so far, or at most
.Ic backdate_grace_hours
before it, for drafts published with their original date and for time zone
slips.
An item without a date counts as dated when it was first seen.
.El
.Pp
So a feed that re-dates old items toots nothing, as they are not new; nor does
one that moves to https or to another domain, or changes its guids, as the
items keep their old dates and are not recent.
Within the grace period, an item with a new guid and a new link that is dated
exactly like a remembered item that is gone from the feed is taken for that
item, renamed.
Only a feed that changes the identities and the dates of its items at once
gets through;
.Ic max_new_items_per_fetch
caps how many items one fetch may toot.
.Pp
A fetch only records items.
Tooting is a separate step, run at least once a minute, which toots each item
once its date has come: a post dated three days ahead is tooted in three
days, whether or not the feed changes again in between, and does not hide the
posts published before it.
.Sh ENVIRONMENT
.Bl -tag -width Ds
.It Ev CONFIG
The configuration file.
Default
.Pa ../dev/config.dev.jsonc ,
relative to the working directory, for development from
.Pa src .
.It Ev SECRETS
The secrets file.
Default
.Pa ../dev/secrets.dev.jsonc .
.El
.Sh FILES
.Bl -tag -width Ds
.It Pa www/
Page templates, assets and
.Pa version.txt ,
whose contents are shown in the page footer and sent in the
.Ql User-Agent
of every request; relative to the working directory.
.It Ic db_file
SQLite: the accounts with their keys, followers, posts, toots, the queue of
toots still to be delivered, and every feed item seen.
Older databases are migrated at startup.
.El
.Sh EXIT STATUS
.Nm
runs until it is stopped.
It exits non-zero at startup if a configuration file cannot be read or
parsed,
.Ic blocked_feeds_file
is set but cannot be opened, or the database cannot be opened or migrated.
.Sh EXAMPLES
Build and run from the Nix package:
.Bd -literal -offset indent
$ nix build .#rss-kakapo
$ cd result/share/rss-kakapo
$ CONFIG=/etc/rss-kakapo/config.jsonc \e
  SECRETS=/run/secrets/rss-kakapo.jsonc \e
  ../../bin/rss-kakapo
.Ed
.Pp
A minimal
.Pa config.jsonc :
.Bd -literal -offset indent
{
  "host": "feeds.example.org",
  "service_port": 8080,
  "db_file": "/var/lib/rss-kakapo/kakapo.db",
  "birb": {
    "user": "birb",
    "published": "2026-01-01T00:00:00Z",
    "pub_key": "-----BEGIN RSA PUBLIC KEY-----\en...",
    "priv_key": "-----BEGIN RSA PRIVATE KEY-----\en..."
  }
}
.Ed
.Pp
Follow a feed through the API:
.Bd -literal -offset indent
$ curl -H "X-API-KEY: $KEY" \e
    -d '{"site_url": "https://blog.example.org"}' \e
    https://feeds.example.org/api/feeds
.Ed
.Sh SEE ALSO
RSS Parrot:
.Lk https://codeberg.org/gugray/rss-parrot
.Pp
Sources:
.Lk https://sources.profpatsch.de/rss-kakapo/
.Sh HISTORY
.Nm
is a fork of RSS Parrot by Gabor L Ugray, taken at v0.0.99; upstream's
history is kept, and the fork's commits sit on top of its
.Ql v0.0.99
tag.
The Go module is still called
.Ql rss_parrot ,
so that upstream commits stay easy to cherry-pick.
Please don't report problems with
.Nm
to RSS Parrot.
.Sh AUTHORS
.An Gabor L Ugray
wrote RSS Parrot;
.An Profpatsch Aq mail@profpatsch.de
maintains the fork.
Both are MIT-licensed; see
.Pa LICENSE .
.Sh CAVEATS
.Ss A migration that also re-dates its posts is tooted again
If a feed changes the guids, the links and the dates of its items at once,
nothing tells them from new posts, and with
.Ic max_new_items_per_fetch
at 0 they are all tooted.
.Ss The memory of items only grows
Every item ever seen stays in the database, a few hundred bytes each, until
its account is deleted.
.Ss The first check of an older account follows upstream's rules
An account followed before items were remembered has none in memory.
Its first check after the upgrade toots what upstream would have \(en items
dated after its newest post, not stored yet \(en and remembers the rest; the
rules above apply from its second check.