OPDS manga server in C++ from raw sockets: Argon2id logins, page streaming, fuzzed parsers, sandboxed systemd service.
C++
0
0 commits
updated Sep 21, 2026
A small manga server I wrote in C++ for my home server. It serves a private library to me and a friend, and we read on our phones with an existing reader app. No app of my own, no cloud, nothing open to the internet.
Mostly a way to learn how servers and their security actually work, by building one from raw sockets instead of installing one. It runs on the same 2009 laptop as everything in homeserver-barebasics.
phone app ── HTTPS ──> tailscale serve ── HTTP ──> mangad ──> /srv/mangad/library
tailnet (on the server) 127.0.0.1 shared/ users/<name>/
only :8090
.cbz files (manga chapters, which are just zips
of images) as an OPDS catalog. OPDS is a standard format reader
apps understand, so the app does all the reading and I only wrote the
server| Port | Who can reach it | RAM |
|---|---|---|
| 8090 | This machine only (127.0.0.1) | capped at 300 MB |
| 8444 | Tailnet only, through tailscale serve (HTTPS) | — |
socket() up.cbz filestailscale serve's job. mangad only listens on 127.0.0.1, so
nothing on the network can reach it directlyI built it in phases, each one working and tested before the next. The git history has them in order: sockets, HTTP parsing, the library scan, OPDS, downloads, logins, pages, fuzzing, deploy.
Everything a client sends is treated as hostile. The rule all the way through: reject anything odd, never try to "fix" it.
400. These are the tricks behind request
smuggling, where a proxy and a server read the same request
differently../../etc/passwd) can't happen, by designopenat2(),
which refuses any symlink and never leaves the library folder. This
closes a TOCTOU race: swapping a chapter for a symlink to
/etc/passwd after the scan checked itsendfile(), so a chapter never passes through mangad's
memory.cbz files are opened, so they're hostile input too. Broken or hostile
archives are dropped at scan time:
../../evil.png) doesn't apply: mangad
never unpacks anything to diskchmod 600401. Unknown names are checked against a
dummy hash so they take as long as real ones, which stops timing
attacks that find out which names exist404, exactly like an ID that doesn't existIt runs as its own user, mangad, in a systemd sandbox. The system is
read-only to it, home folders don't exist, the library is read-only, it
can only talk to 127.0.0.1, it has no privileges and can't gain any, and
it's capped at 300MB of RAM so it can't starve Minecraft.
systemd-analyze security scores how exposed a service is, from 0 (locked
down) to 10:
| Score | |
|---|---|
| Same service, no sandbox | 9.0 UNSAFE |
| mangad | 1.2 OK |
The rest is things it genuinely needs, like a network socket.
tests/, one file per phase. They send real requests,
including every attack above, and check the answersmake testlib builds a fake library to test against: coloured squares
for pages, plus traps like symlinks to /etc, a zip bomb, a zip with
a lying header, and a file name that isn't valid UTF-8make debug builds with AddressSanitizer and UBSan, which crash loudly
on any memory bug. The tests run against that buildmake fuzzrun runs 6 libFuzzer fuzzers against every
parser: HTTP, Range headers, Basic auth, XML escaping, the sort order
and zip listing. They don't just check for crashes. Each one also
checks rules that must always hold, like "an accepted range never goes
past the end of the file". About 40 million inputs found nothingOn the server, after the base setup from homeserver-barebasics:
git clone https://github.com/<you>/mangad.git ~/src/mangad
~/src/mangad/install.sh
install.sh does everything, and is safe to run again to upgrade:
/usr/local/binmangad system user with no login and no home/srv/mangad/library. You own it, mangad can
only read ittailscale serve on port 8444, and opens
that port on tailscale0 onlyIn the reader app, add an OPDS catalog at
https://<host>.<tailnet>.ts.net:8444/opds and log in.
Any app that reads OPDS 1.x catalogs and supports a username and password should work. Your phone needs Tailscale connected. These are free, and I've tested both on my iPhone:
| App | Notes |
|---|---|
| Readest | Open source, iOS and Android. Downloads whole volumes, makes covers from the first page |
| Euria | Reads manga well. Supports OPDS page streaming |
If an app says "invalid OPDS feed", check Tailscale is on first. That was my problem.
scp -r "Series Name" <server>:/srv/mangad/library/shared/ # everyone
scp -r "Series Name" <server>:/srv/mangad/library/users/<name>/ # just them
One folder per series, one .cbz per chapter. It shows up within 10
minutes.
sudo -u mangad mangad adduser /var/lib/mangad/users <name>
sudo -u mangad mangad passwd /var/lib/mangad/users <name>
sudo -u mangad mangad deluser /var/lib/mangad/users <name>
sudo install -d -o $USER -g mangad -m 2750 /srv/mangad/library/users/<name>
Changes work without a restart.
make && make debug && make testlib
./mangad-debug serve ~/manga-test ~/manga-test.users # one terminal
make test # another
make fuzzrun # needs clang
The "headers too big" test got an empty reply, even though the log said
mangad sent 431. The client was still sending when mangad called
close(). Closing a socket with unread data makes Linux send a TCP
reset, and the reset can wipe out the reply before the client reads it.
Now mangad stops writing, reads and throws away what's left (at most 1
second and 64KB), then closes. nginx does the same thing.
The first adduser failed with "Socket operation on non-socket". The
users file was being written with the same helper as network replies,
which uses send(), and send() only works on sockets. Files get
write() now.
Some test runs passed against an old copy of mangad that was still
running, because the new one couldn't take the port. The new one said
bind: Address already in use and quit, and the tests never noticed.
Check the server actually started before trusting a test run.
A botched scp left a file on the server whose name was just a newline.
The scan log printed it raw, so one log line broke into two. I'd blocked
log injection for user names, but not for file names, or for the names
of files inside a .cbz. Now every scan log line has control characters
turned into \x0a style escapes, and the test library has a file named
notes\nscan: 999 series, 999 chapters to keep it fixed.
On the server every chapter failed with "can't open", but mangad's user
could read the files fine. The difference was the systemd sandbox:
RestrictSUIDSGID=yes makes systemd block openat2() completely,
because seccomp can't see inside the struct it takes its flags in. My
tests never caught it, because they ran outside the sandbox. Now mangad
falls back to opening the path one folder at a time with O_NOFOLLOW,
the way it was done before openat2() existed, which gives the same
protection. MANGAD_NO_OPENAT2=1 forces the fallback so the tests
cover it too.
tailscale serve, every
request comes from 127.0.0.1, so banning one would ban everyone. The
per-name lockout does that job instead. The log still records the
real tailnet address and account, from the headers tailscale serve
adds, but those are never trusted for accesstailscale serve wraps it all in HTTPSsystemctl status mangad # is it running?
journalctl -u mangad -f # live log, with logins
journalctl -u mangad | grep 401 # failed logins
systemd-analyze security mangad # the sandbox, line by line
tailscale serve status # the address for the app
src/ the server
http request parsing, limits, replies
library the scan, IDs, who sees what
opds the XML feeds
download whole files and ranges, symlink-proof opens
pages single pages out of .cbz files
auth users, Argon2id, lockout
tests/ phase2.sh to phase7.sh, and the fake library builder
fuzz/ the 6 fuzzers
mangad.service the sandboxed systemd unit
install.sh builds and installs everything on the server
C++
72.5%
Shell
19.5%
Python
6.7%
Makefile
1.3%
OPDS manga server in C++ from raw sockets: Argon2id logins, page streaming, fuzzed parsers, sandboxed systemd service.
C++
0
0 commits
updated Sep 21, 2026
A small manga server I wrote in C++ for my home server. It serves a private library to me and a friend, and we read on our phones with an existing reader app. No app of my own, no cloud, nothing open to the internet.
Mostly a way to learn how servers and their security actually work, by building one from raw sockets instead of installing one. It runs on the same 2009 laptop as everything in homeserver-barebasics.
phone app ── HTTPS ──> tailscale serve ── HTTP ──> mangad ──> /srv/mangad/library
tailnet (on the server) 127.0.0.1 shared/ users/<name>/
only :8090
.cbz files (manga chapters, which are just zips
of images) as an OPDS catalog. OPDS is a standard format reader
apps understand, so the app does all the reading and I only wrote the
server| Port | Who can reach it | RAM |
|---|---|---|
| 8090 | This machine only (127.0.0.1) | capped at 300 MB |
| 8444 | Tailnet only, through tailscale serve (HTTPS) | — |
socket() up.cbz filestailscale serve's job. mangad only listens on 127.0.0.1, so
nothing on the network can reach it directlyI built it in phases, each one working and tested before the next. The git history has them in order: sockets, HTTP parsing, the library scan, OPDS, downloads, logins, pages, fuzzing, deploy.
Everything a client sends is treated as hostile. The rule all the way through: reject anything odd, never try to "fix" it.
400. These are the tricks behind request
smuggling, where a proxy and a server read the same request
differently../../etc/passwd) can't happen, by designopenat2(),
which refuses any symlink and never leaves the library folder. This
closes a TOCTOU race: swapping a chapter for a symlink to
/etc/passwd after the scan checked itsendfile(), so a chapter never passes through mangad's
memory.cbz files are opened, so they're hostile input too. Broken or hostile
archives are dropped at scan time:
../../evil.png) doesn't apply: mangad
never unpacks anything to diskchmod 600401. Unknown names are checked against a
dummy hash so they take as long as real ones, which stops timing
attacks that find out which names exist404, exactly like an ID that doesn't existIt runs as its own user, mangad, in a systemd sandbox. The system is
read-only to it, home folders don't exist, the library is read-only, it
can only talk to 127.0.0.1, it has no privileges and can't gain any, and
it's capped at 300MB of RAM so it can't starve Minecraft.
systemd-analyze security scores how exposed a service is, from 0 (locked
down) to 10:
| Score | |
|---|---|
| Same service, no sandbox | 9.0 UNSAFE |
| mangad | 1.2 OK |
The rest is things it genuinely needs, like a network socket.
tests/, one file per phase. They send real requests,
including every attack above, and check the answersmake testlib builds a fake library to test against: coloured squares
for pages, plus traps like symlinks to /etc, a zip bomb, a zip with
a lying header, and a file name that isn't valid UTF-8make debug builds with AddressSanitizer and UBSan, which crash loudly
on any memory bug. The tests run against that buildmake fuzzrun runs 6 libFuzzer fuzzers against every
parser: HTTP, Range headers, Basic auth, XML escaping, the sort order
and zip listing. They don't just check for crashes. Each one also
checks rules that must always hold, like "an accepted range never goes
past the end of the file". About 40 million inputs found nothingOn the server, after the base setup from homeserver-barebasics:
git clone https://github.com/<you>/mangad.git ~/src/mangad
~/src/mangad/install.sh
install.sh does everything, and is safe to run again to upgrade:
/usr/local/binmangad system user with no login and no home/srv/mangad/library. You own it, mangad can
only read ittailscale serve on port 8444, and opens
that port on tailscale0 onlyIn the reader app, add an OPDS catalog at
https://<host>.<tailnet>.ts.net:8444/opds and log in.
Any app that reads OPDS 1.x catalogs and supports a username and password should work. Your phone needs Tailscale connected. These are free, and I've tested both on my iPhone:
| App | Notes |
|---|---|
| Readest | Open source, iOS and Android. Downloads whole volumes, makes covers from the first page |
| Euria | Reads manga well. Supports OPDS page streaming |
If an app says "invalid OPDS feed", check Tailscale is on first. That was my problem.
scp -r "Series Name" <server>:/srv/mangad/library/shared/ # everyone
scp -r "Series Name" <server>:/srv/mangad/library/users/<name>/ # just them
One folder per series, one .cbz per chapter. It shows up within 10
minutes.
sudo -u mangad mangad adduser /var/lib/mangad/users <name>
sudo -u mangad mangad passwd /var/lib/mangad/users <name>
sudo -u mangad mangad deluser /var/lib/mangad/users <name>
sudo install -d -o $USER -g mangad -m 2750 /srv/mangad/library/users/<name>
Changes work without a restart.
make && make debug && make testlib
./mangad-debug serve ~/manga-test ~/manga-test.users # one terminal
make test # another
make fuzzrun # needs clang
The "headers too big" test got an empty reply, even though the log said
mangad sent 431. The client was still sending when mangad called
close(). Closing a socket with unread data makes Linux send a TCP
reset, and the reset can wipe out the reply before the client reads it.
Now mangad stops writing, reads and throws away what's left (at most 1
second and 64KB), then closes. nginx does the same thing.
The first adduser failed with "Socket operation on non-socket". The
users file was being written with the same helper as network replies,
which uses send(), and send() only works on sockets. Files get
write() now.
Some test runs passed against an old copy of mangad that was still
running, because the new one couldn't take the port. The new one said
bind: Address already in use and quit, and the tests never noticed.
Check the server actually started before trusting a test run.
A botched scp left a file on the server whose name was just a newline.
The scan log printed it raw, so one log line broke into two. I'd blocked
log injection for user names, but not for file names, or for the names
of files inside a .cbz. Now every scan log line has control characters
turned into \x0a style escapes, and the test library has a file named
notes\nscan: 999 series, 999 chapters to keep it fixed.
On the server every chapter failed with "can't open", but mangad's user
could read the files fine. The difference was the systemd sandbox:
RestrictSUIDSGID=yes makes systemd block openat2() completely,
because seccomp can't see inside the struct it takes its flags in. My
tests never caught it, because they ran outside the sandbox. Now mangad
falls back to opening the path one folder at a time with O_NOFOLLOW,
the way it was done before openat2() existed, which gives the same
protection. MANGAD_NO_OPENAT2=1 forces the fallback so the tests
cover it too.
tailscale serve, every
request comes from 127.0.0.1, so banning one would ban everyone. The
per-name lockout does that job instead. The log still records the
real tailnet address and account, from the headers tailscale serve
adds, but those are never trusted for accesstailscale serve wraps it all in HTTPSsystemctl status mangad # is it running?
journalctl -u mangad -f # live log, with logins
journalctl -u mangad | grep 401 # failed logins
systemd-analyze security mangad # the sandbox, line by line
tailscale serve status # the address for the app
src/ the server
http request parsing, limits, replies
library the scan, IDs, who sees what
opds the XML feeds
download whole files and ranges, symlink-proof opens
pages single pages out of .cbz files
auth users, Argon2id, lockout
tests/ phase2.sh to phase7.sh, and the fake library builder
fuzz/ the 6 fuzzers
mangad.service the sandboxed systemd unit
install.sh builds and installs everything on the server
C++
72.5%
Shell
19.5%
Python
6.7%
Makefile
1.3%