tafseeriqbal/mangad

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

See the code

See what people are saying

SourceMessageScoreDate

4GB Toshiba laptop running Pi-hole, Minecraft and Tailscale (r/homelab)

Just got my first server running using parts I already had lying around. Not much lowkey **Hardware** - Old Toshiba laptop, Pentium T4400, 4GB RAM (maxed out) - 256GB SATA SSD pulled from a dead HP Pavilion - Ubuntu Server 24.04 **What it runs** - **Tailscale** – the only way in. No port…

0

Oct 2, 2026

README

mangad

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

What it does

  • Serves a folder of .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
  • Page streaming (OPDS-PSE): the app asks for one page at a time out of a chapter, so reading starts right away
  • Downloads with resume, for reading offline
  • Logins, with a shared folder everyone sees plus a private folder per user
  • New chapters show up by themselves. It rescans every 10 minutes
PortWho can reach itRAM
8090This machine only (127.0.0.1)capped at 300 MB
8444Tailnet only, through tailscale serve (HTTPS)—

How it's built

  • C++17, no web framework. The HTTP server is written from socket() up
  • Two libraries: libsodium for password hashing, libzip for reading pages out of .cbz files
  • One thread per client, at most 32 at once
  • HTTPS is tailscale serve's job. mangad only listens on 127.0.0.1, so nothing on the network can reach it directly

I 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.

Security

Everything a client sends is treated as hostile. The rule all the way through: reject anything odd, never try to "fix" it.

Requests

  • Strict HTTP parsing. Odd line endings, spaces before colons, folded or duplicate headers all get 400. These are the tricks behind request smuggling, where a proxy and a server read the same request differently
  • No request bodies at all, so there's no body parsing to get wrong
  • Limits on everything: 8KB of headers, 10 seconds to send them (in total, not per read, which is what stops slowloris), 32 clients
  • A reply isn't cut off by a TCP reset when the client is still sending (see "Things that went wrong")

Files

  • No file paths in URLs. Every series and chapter gets an ID at scan time, and a URL can only reach something the scan found. Path traversal (../../etc/passwd) can't happen, by design
  • Symlinks are skipped by the scan. Files are opened with openat2(), 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 it
  • Downloads use sendfile(), so a chapter never passes through mangad's memory

Archives

.cbz files are opened, so they're hostile input too. Broken or hostile archives are dropped at scan time:

  • Zip bombs: no page may unpack past 16MB
  • At most 5,000 files and 2,000 pages per chapter
  • A zip header's size is just a number someone wrote, so reading stops at 16MB no matter what the header claims
  • Zip slip (entries named ../../evil.png) doesn't apply: mangad never unpacks anything to disk

Logins

  • Passwords are hashed with Argon2id. Only hashes are stored, in a file mangad refuses to use unless it's chmod 600
  • Argon2id is slow and takes 64MB per check, on purpose. At most 2 run at once, or 32 people guessing would need 2GB
  • A good login is remembered for 10 minutes (as a keyed hash, not the password), because the app sends the password with every request
  • 5 wrong passwords lock that name for 15 minutes
  • Every failure gets the same 401. 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 exist
  • Asking for someone else's private series or chapter by ID (IDOR) gets 404, exactly like an ID that doesn't exist
  • Names only reach the log after passing a strict check, so nobody can forge log lines (log injection)

Output

  • Everything in the XML feeds is escaped, and broken UTF-8 in file names is replaced, so an odd file name can't break the feed or inject markup
  • File names in download headers are cleaned, so a newline in a name can't add headers of its own (header injection)

The service

It 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 sandbox9.0 UNSAFE
mangad1.2 OK

The rest is things it genuinely needs, like a network socket.

Testing

  • 131 tests in tests/, one file per phase. They send real requests, including every attack above, and check the answers
  • make 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-8
  • make debug builds with AddressSanitizer and UBSan, which crash loudly on any memory bug. The tests run against that build
  • Fuzzing. make 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 nothing

Setup

On 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:

  1. Installs the build tools and libraries, builds mangad, installs it to /usr/local/bin
  2. Makes a mangad system user with no login and no home
  3. Makes the library at /srv/mangad/library. You own it, mangad can only read it
  4. Asks for the first user name and password
  5. Installs and starts the sandboxed service
  6. Puts it on the tailnet with tailscale serve on port 8444, and opens that port on tailscale0 only
  7. Prints the exposure score and the address for your app

In the reader app, add an OPDS catalog at https://<host>.<tailnet>.ts.net:8444/opds and log in.

Reading apps

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:

AppNotes
ReadestOpen source, iOS and Android. Downloads whole volumes, makes covers from the first page
EuriaReads manga well. Supports OPDS page streaming

If an app says "invalid OPDS feed", check Tailscale is on first. That was my problem.

Adding manga

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.

Users

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.

Developing

make && make debug && make testlib
./mangad-debug serve ~/manga-test ~/manga-test.users    # one terminal
make test                                              # another
make fuzzrun                                           # needs clang

Things that went wrong

1. The reply that vanished

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.

2. Writing a file with a socket function

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.

3. Testing the wrong server

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.

4. A file name that wrote its own log line

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.

5. The sandbox blocked the safest way to open files

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.

Known gaps

  • Anyone who can reach mangad can lock a user out, by guessing wrong 5 times. That's the cost of the lockout. Only my tailnet can reach it
  • IP bans (fail2ban) don't work here: behind 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 access
  • Basic auth sends the password with every request. That's only safe because tailscale serve wraps it all in HTTPS
  • Lockouts and remembered logins live in memory, so a restart clears them
  • libzip itself isn't fuzzed with sanitizers, only my code around it
  • The tests need the server started by hand in another terminal

Useful commands

systemctl 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

What's in this repo

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

Also by me

bobserver
cpp
homelab
manga
opds
self-hosted

tafseeriqbal/mangad

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

See the code

See what people are saying

SourceMessageScoreDate

4GB Toshiba laptop running Pi-hole, Minecraft and Tailscale (r/homelab)

Just got my first server running using parts I already had lying around. Not much lowkey **Hardware** - Old Toshiba laptop, Pentium T4400, 4GB RAM (maxed out) - 256GB SATA SSD pulled from a dead HP Pavilion - Ubuntu Server 24.04 **What it runs** - **Tailscale** – the only way in. No port…

0

Oct 2, 2026

README

mangad

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

What it does

  • Serves a folder of .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
  • Page streaming (OPDS-PSE): the app asks for one page at a time out of a chapter, so reading starts right away
  • Downloads with resume, for reading offline
  • Logins, with a shared folder everyone sees plus a private folder per user
  • New chapters show up by themselves. It rescans every 10 minutes
PortWho can reach itRAM
8090This machine only (127.0.0.1)capped at 300 MB
8444Tailnet only, through tailscale serve (HTTPS)—

How it's built

  • C++17, no web framework. The HTTP server is written from socket() up
  • Two libraries: libsodium for password hashing, libzip for reading pages out of .cbz files
  • One thread per client, at most 32 at once
  • HTTPS is tailscale serve's job. mangad only listens on 127.0.0.1, so nothing on the network can reach it directly

I 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.

Security

Everything a client sends is treated as hostile. The rule all the way through: reject anything odd, never try to "fix" it.

Requests

  • Strict HTTP parsing. Odd line endings, spaces before colons, folded or duplicate headers all get 400. These are the tricks behind request smuggling, where a proxy and a server read the same request differently
  • No request bodies at all, so there's no body parsing to get wrong
  • Limits on everything: 8KB of headers, 10 seconds to send them (in total, not per read, which is what stops slowloris), 32 clients
  • A reply isn't cut off by a TCP reset when the client is still sending (see "Things that went wrong")

Files

  • No file paths in URLs. Every series and chapter gets an ID at scan time, and a URL can only reach something the scan found. Path traversal (../../etc/passwd) can't happen, by design
  • Symlinks are skipped by the scan. Files are opened with openat2(), 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 it
  • Downloads use sendfile(), so a chapter never passes through mangad's memory

Archives

.cbz files are opened, so they're hostile input too. Broken or hostile archives are dropped at scan time:

  • Zip bombs: no page may unpack past 16MB
  • At most 5,000 files and 2,000 pages per chapter
  • A zip header's size is just a number someone wrote, so reading stops at 16MB no matter what the header claims
  • Zip slip (entries named ../../evil.png) doesn't apply: mangad never unpacks anything to disk

Logins

  • Passwords are hashed with Argon2id. Only hashes are stored, in a file mangad refuses to use unless it's chmod 600
  • Argon2id is slow and takes 64MB per check, on purpose. At most 2 run at once, or 32 people guessing would need 2GB
  • A good login is remembered for 10 minutes (as a keyed hash, not the password), because the app sends the password with every request
  • 5 wrong passwords lock that name for 15 minutes
  • Every failure gets the same 401. 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 exist
  • Asking for someone else's private series or chapter by ID (IDOR) gets 404, exactly like an ID that doesn't exist
  • Names only reach the log after passing a strict check, so nobody can forge log lines (log injection)

Output

  • Everything in the XML feeds is escaped, and broken UTF-8 in file names is replaced, so an odd file name can't break the feed or inject markup
  • File names in download headers are cleaned, so a newline in a name can't add headers of its own (header injection)

The service

It 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 sandbox9.0 UNSAFE
mangad1.2 OK

The rest is things it genuinely needs, like a network socket.

Testing

  • 131 tests in tests/, one file per phase. They send real requests, including every attack above, and check the answers
  • make 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-8
  • make debug builds with AddressSanitizer and UBSan, which crash loudly on any memory bug. The tests run against that build
  • Fuzzing. make 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 nothing

Setup

On 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:

  1. Installs the build tools and libraries, builds mangad, installs it to /usr/local/bin
  2. Makes a mangad system user with no login and no home
  3. Makes the library at /srv/mangad/library. You own it, mangad can only read it
  4. Asks for the first user name and password
  5. Installs and starts the sandboxed service
  6. Puts it on the tailnet with tailscale serve on port 8444, and opens that port on tailscale0 only
  7. Prints the exposure score and the address for your app

In the reader app, add an OPDS catalog at https://<host>.<tailnet>.ts.net:8444/opds and log in.

Reading apps

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:

AppNotes
ReadestOpen source, iOS and Android. Downloads whole volumes, makes covers from the first page
EuriaReads manga well. Supports OPDS page streaming

If an app says "invalid OPDS feed", check Tailscale is on first. That was my problem.

Adding manga

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.

Users

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.

Developing

make && make debug && make testlib
./mangad-debug serve ~/manga-test ~/manga-test.users    # one terminal
make test                                              # another
make fuzzrun                                           # needs clang

Things that went wrong

1. The reply that vanished

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.

2. Writing a file with a socket function

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.

3. Testing the wrong server

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.

4. A file name that wrote its own log line

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.

5. The sandbox blocked the safest way to open files

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.

Known gaps

  • Anyone who can reach mangad can lock a user out, by guessing wrong 5 times. That's the cost of the lockout. Only my tailnet can reach it
  • IP bans (fail2ban) don't work here: behind 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 access
  • Basic auth sends the password with every request. That's only safe because tailscale serve wraps it all in HTTPS
  • Lockouts and remembered logins live in memory, so a restart clears them
  • libzip itself isn't fuzzed with sanitizers, only my code around it
  • The tests need the server started by hand in another terminal

Useful commands

systemctl 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

What's in this repo

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

Also by me

bobserver
cpp
homelab
manga
opds
self-hosted

Languages

C++

72.5%

Shell

19.5%

Python

6.7%

Makefile

1.3%