Keeping it old school, Unix style, with inetd services!
As summarised by Peter Salus in his book, "A Quarter Century of UNIX" , the Unix philosophy is:
- Write programs that do one thing and do it well.
- Write programs to work together.
- Write programs to handle text streams, because that is a universal interface.
inted
is a great example of this philosophy.
It's a daemon that's responsible for listening for incoming connections, spawning child processes for each connection and piping the network traffic to the child processes' stdin and stdout.
This way, the individual services don't need to worry about how to handle network traffic, and the daemon doesn't need to support every protocol the system might want to provide.
To show how simple it is to write network services in this fasion, the following Rust snippet is a complete implementation of the
discard
service
.
use std::io::{self, Read};
fn main() -> io::Result<()> {
// Grab a lock on stdin.
let mut input = io::stdin().lock();
// Set aside some space to read whatever the client
// throws at us.
let mut byte = [0u8; 1];
// Read the client's input, discarding it, one byte
// at a time.
while input.read(&mut byte)? == 1 {
// Do nothing.
}
// We're done.
Ok(())
}
Admittedly, the
discard
service is a bit silly, just taking the client's traffic and throwing it away, but it's a good example of how simple it is to write a network service in this style.
inetd
is a bit
too
old for modern Linux systems, but
xinetd
is
still around
and supported on many systems.
This small snippet is all that's needed to wire
xinetd
up to run our
discard
service on demand.
service simple-discard
{
disable = no
socket_type = stream
protocol = tcp
wait = no
user = nobody
port = 909
server = /opt/simple-inetd-services/bin/discard
type = UNLISTED
}
With that configuration in place, we can test our new
discard
service by connecting to it with
nc
.
No matter what you send to it, it'll just be thrown away.
$ nc localhost 909
For the purposes of completeness, there are two more services that we can write in this style.
These are
chargen
and
echo
.
Chargen is a simple service that just "generates characters" and sends them to the client. According to RFC 864 ,
The data may be anything. It is recommended that a recognizable pattern be used in tha data.
We can implement this service with the following Rust snippet, which will continuously send a repeating pattern of ASCII printable characters to the client.
use std::io::{self, Write};
fn main() -> io::Result<()> {
// Grab a lock on stdout.
let mut output = io::stdout().lock();
// Start at a space.
let mut curr_char: u8 = 32;
// Forever, forever.
loop {
// Write the current character to stdout.
output.write_all(&[curr_char])?;
output.flush()?;
// Advance to the next character.
curr_char += 1;
// Wrap around to a space if we're at the end of
// the ASCII range.
if curr_char > 126 {
curr_char = 32;
}
}
}
Repeating a small snippet of
xinetd
configuration will run our
chargen
service, and we can test it using
nc
as before.
This time, we'll see a repeating pattern of ASCII printable characters, but that's it.
$ nc localhost 1919
!"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVW
XYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~ !"#$%&'()*+,-./0
123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefgh
ijklmnopqrstuvwxyz{|}~ !"#$%&'()*+,-./0123456789:;<=>?@A
BCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxy
z{|}~ !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQR
STUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~ !"#$%&'()*+
,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abc
defghijklmnopqrstuvwxyz{|}~ !"#$%&'()*+,-./0123456789:;<
=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrst
uvwxyz{|}~ !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLM
This leaves us with the
echo
service.
This service is a little more complicated,
as it needs to handle both reading and writing to the client.
According to RFC 862 ,
Once a connection is established any data received is sent back. This continues until the calling user terminates the connection.
We can handle this by reading a single byte from the client and writing it back to them.
use std::io::{self, Read, Write};
fn main() -> io::Result<()> {
// Grab a lock on stdin and stdout.
let mut input = io::stdin().lock();
let mut output = io::stdout().lock();
// Set aside some space to read whatever the client
// throws at us.
let mut byte = [0u8; 1];
// Read the client's input, echoing it back to them,
// one byte at a time.
while input.read(&mut byte)? == 1 {
output.write_all(&byte)?;
output.flush()?;
}
// We're done.
Ok(())
}
Again, adding some
xinetd
configuration lets us test this with
nc
.
$ nc localhost 707
a
a
b
b
c
c
d
d
What's not clear here is if our service is actually responding correctly.
It appears that it's waiting for a line break to echo back the data.
This could be a problem with how we're reading from stdin, or it could be the terminal we're using to run
nc
itself.
We can test this hypothesis in two ways.
The first is the naive, empirical, approach.
We can use the
chargen
service as
echo
's input, as opposed to the terminal keystrokes.
The output of one
nc
session can be piped into the input of another and the screen output observed.
$ nc localhost 1919 | nc localhost 707
!"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVW
XYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~ !"#$%&'()*+,-./0
123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefgh
ijklmnopqrstuvwxyz{|}~ !"#$%&'()*+,-./0123456789:;<=>?@A
BCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxy
z{|}~ !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQR
STUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~ !"#$%&'()*+
,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abc
defghijklmnopqrstuvwxyz{|}~ !"#$%&'()*+,-./0123456789:;<
=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrst
uvwxyz{|}~ !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLM
Inspecting the system with
top
, shows us the two
nc
sessions, and the two services,
chargen
and
echo
running.
PID USER %CPU %MEM COMMAND
9751 mike 100.0 0.0 nc
9753 nobody 100.0 0.0 chargen
9754 nobody 100.0 0.0 echo
9752 mike 90.9 0.0 nc
OK, so we can see that the
echo
service is working correctly.
An alternative way we could have tested this would have been to use a debugger to set some breakpoints and step through the code.
This is tricky, since we're not directly running the process, from our IDE or debugger for example, but asking
xinetd
to spawn the new process for us.
We need to find a way to attach to this new process.
One option is to add the following small snippet of code to the
main
function of our
echo
service.
This will automatically stop the process if it's been built in debug mode, giving us time to attach to it.
We can then use the debugger to resume the process, and step line-by-line through the code, inspecting the variables and state of the program.
fn main() -> io::Result<()> {
+ // If we've been built for debugging, stop the
+ // process so we can attach our debugger.
+ #[cfg(all(target_family = "unix", debug_assertions))]
+ nix::sys::signal::raise(
+ nix::sys::signal::Signal::SIGSTOP
+ )?;
+
// Grab a lock on stdin and stdout.
let mut input = io::stdin().lock();
let mut output = io::stdout().lock();
This is a bit of a hack, but it works. It will only work on Unix-ish-es, since we're using the SIGSTOP POSIX signal, which basically just asks the OS' job control system to pause our process when that line of code is reached.
If you want to have a play around with these examples, I've wrapped all of this code up in a
Codeberg repository
that you can clone and build yourself.
It includes the source code for all the services we've discussed, copies of the
xinetd
configuration files, and a Makefile to build and install everything.
These programs are free software: you can redistribute them and/or modify them under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
2026-04-23