gopherc is a small Gopher server for Unix-like systems written in C11 with POSIX sockets and no external runtime dependencies.
It serves content from a document root, supports directories, text files, and binary files, and is intentionally conservative about filesystem access and protocol handling.
- Gopher menus for directories
- Text file serving with CRLF normalization and dot-stuffing
- Binary file serving (with
sendfileoptimization on Linux/FreeBSD) - Empty selector maps to the document root
- Hidden files denied by default
- Path traversal rejected
- Symlink traversal refused with
O_NOFOLLOW - Optional
gophermapoverride for directory menus - Fork-per-connection concurrency
- Optional privilege drop after bind/listen
- Optional child-process limit for basic backpressure
- IPv6 dual-stack support
- Connection read timeout (configurable)
- Access logging in Common Log Format
- Daemonization (fork to background)
- Optional chroot jail after privilege drop
- Configuration file support
- Verbose/debug logging
- Graceful shutdown with child reaping
Requirements:
cc,gcc, orclangmake- Python 3 for the regression test script
Build the server:
makeRun the regression tests:
make testRun the C unit tests:
make unit-testClean build outputs:
make cleanServe the bundled example tree on localhost port 7070:
./gopherd -r example-root -a 127.0.0.1 -p 7070 -n localhostFetch the root menu with a simple client:
python3 - <<'PY'
import socket
s = socket.create_connection(("127.0.0.1", 7070))
s.sendall(b"\r\n")
print(s.recv(4096).decode("utf-8", "replace"))
PY-r DOCROOT document root to serve (required)
-a ADDRESS bind address, default: 0.0.0.0
-p PORT TCP port, default: 7070
-n HOSTNAME hostname advertised in generated menus
-u USER drop privileges to this user after binding
-g GROUP drop privileges to this group after binding
-m COUNT max concurrent child workers, 0 means unlimited
-t SECONDS connection read timeout, default: 30, 0=off
-c DIR chroot to this directory after privilege drop
-l FILE path to access log file (appended)
-f FILE read configuration from file
-A allow hidden files and directories
-6 enable IPv6 (dual-stack by default, single-stack with -a)
-d daemonize (fork to background after bind)
-v verbose/debug logging
-V show version
-h show help
Notes:
-ris required.- If
-nis omitted, generated menus use the bind address when possible, otherwiselocalhost. - If
-uis set, supplementary groups are dropped withinitgroups()beforesetgid()andsetuid(). - If
-mis reached, new connections are refused by the parent instead of spawning more workers. - When using
-c(chroot), the document root path (-r) should be relative to the chroot directory. - IPv6 dual-stack is used by default. Pass
-6with-a ::1for IPv6-only. Without-6, the bind address determines the family. - Configuration file format is
key=value(one per line). Lines starting with#or;are comments.
Selectors map to paths under the configured root.
By default:
- names beginning with
.are ignored in menus and cannot be requested .and..path components are rejected- symlinks are refused during traversal
- only regular files and directories are served
Item type inference is intentionally simple:
- directories are type
1 - known text-like extensions are type
0 - everything else defaults to type
9
If a directory contains a file named gophermap, it overrides the generated listing for that directory.
Supported line forms:
- no tabs: emitted as an informational
iitem using the server hostname and port - three or more tabs: emitted verbatim as a menu line
Any other line form is treated as invalid and the request fails.
Example:
Subdirectory banner
0Read the nested file /subdir/readme.md localhost 7070
The repository includes a sample tree under example-root/:
example-root/
├── docs/
│ └── welcome.txt
├── files/
│ └── blob.bin
├── index.txt
└── subdir/
├── gophermap
└── readme.md
The config file uses simple key=value pairs, one per line. Lines starting with # or ; are ignored as comments.
# gopherd configuration
docroot=/var/gopher
bind_addr=0.0.0.0
port=70
hostname=gopher.example.com
user=nobody
max_children=20
timeout=30
allow_hidden=false
ipv6_only=false
daemonize=true
verbose=false
access_log=/var/log/gopherd/access.log
chroot_dir=/var/gopher
CLI flags override config file values.
Source layout:
src/main.c: CLI parsing, config file loading, access log setupsrc/server.c: socket setup, signals, fork-per-connection loop, privilege drop, chroot, daemonizationsrc/request.c: bounded selector parsing and secure path resolutionsrc/response.c: menus,gophermap, text and binary responses (with sendfile)src/util.c: logging (error, debug, access log) and small helperssrc/config.c: configuration file parsertests/regression.py: end-to-end smoke teststests/test_unit.c: C-level unit tests for pure functions
This server is designed to preserve a small set of explicit invariants:
- selector input is bounded and treated as untrusted
- accesses stay inside the configured document root
- partial writes are handled correctly
- file descriptors are closed on all main paths
- text responses use valid Gopher line termination
- menus terminate with
.\r\n - optional chroot jail after privilege drop
- connection read timeout prevents resource exhaustion
- The server uses
fork()per connection. - Children are reaped with
SIGCHLDandwaitpid(..., WNOHANG). SIGINTandSIGTERMtrigger a graceful shutdown: the listen socket is closed, remaining children are waited for.SIGPIPEis ignored.SO_REUSEADDRis enabled on the listening socket.SO_RCVTIMEOis set on client sockets to enforce the read timeout.- Binary files use
sendfile()on Linux and FreeBSD for zero-copy delivery.
- No authentication or authorization
- No seccomp or pledge-style sandbox
- No MIME detection beyond filename-extension heuristics
- No advanced Gopher+ features
Typical workflow:
make
make unit-test
make test
./gopherd -r example-root -a 127.0.0.1 -p 7070 -n localhost