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