Skip to content

comps/trivial-network-server

Repository files navigation

## ACKNOWLEDGEMENT ##

This "trivial network server" originally comes from the audit-test project,
where it was named "network server" (or "ns2") and was primarily used for
providing services to systems under test (such as remote auditing setup,
verifying SELinux labels of connected sockets, IPSec sanity checks, etc.).

## OVERVIEW ##

This is "tns", a (hopefully) trivial "network server" (not /name/ server)
which can be thought of as a request-response TCP netcat-like server,
for generic use.

It consists of one main process, which can listen on multiple IPv4/IPv6 addrs
and can serve multiple clients via a typical "forking server" design.

The code should be easy enough to read, this README is just supplementary.

## CLIENT PROTOCOL ##

It uses a simple text-based protocol (but transfers data in binary!) initiated
by the client by sending a newline-terminated "control cmdline" with commands
separated by `;' (semicolon) and command arguments separated by `,' (comma).
These characters are considered special and cannot be escaped. Any other
characters (excl. newline) are non-special and treated equally (incl. spaces).

Q: Why not use spaces?
This leads to hell. When using spaces, the user expects a shell-like behavior,
like treating multiple spaces as one, escape support, escape of the escape char,
argument quoting, etc. By using commas and semicolons, we break the habit of
expecting shell-like parsing and force the user to specify cmdline rigorously.

The cmdline is parsed in each fork()ed child separately and commands are
executed one by one, from left to right. When the cmdline reaches its end,
the client socket is closed and the child exits.

Q: Why not read another cmdline?
Partly because we would need to always specify an exit-like command, even for
simple cmdlines, but mainly because we allow the commands to treat the client
socket as a text/binary input/output for their purposes - when a command was
running, it could freely read another cmdline destined for us and we'd have no
control over it. Therefore we voluntarily *give up* any responsibility for
the client socket as soon as the first newline (end of control cmdline)
is detected.

Once again - this is a major feature - we take super extra care using MSG_PEEK
to not read anything beyond the first newline. This means that any command can
implement its own text (or binary!) protocol as long as it can guarantee to
exit at some point (allow other commands to run) and "eat" everything related
to its protocol from the client socket before exiting, to not confuse other
commands (!!).

## RETURN CODES ##

The parse() function of each command can return any integer value, which is
held in a temporary buffer in the form of '$rc $cmd\n' and sent back to
the client when requested via the `ctl-status' command.

Returning a negative value from a command parser causes the ctl cmdline-parsing
process to quit immediately with a fatal error, without even being able to
request statuses for previous commands.

Whenever a fatal error or other server-specific internal error occurs, the
TCP connection is forcefuly reset (TCP RST), allowing the client to detect
a remote error.

## COMMAND PROGRAMMING INTERFACE ##

See any of the simple commands like 'echo' or 'cat' on how you can make your
own command. The mandatory thing is to provide a parse() function, which we
call with a session_info structure, specifying the client socket, argc+argv
the user specified on the control cmdline and more. This function is the core
of your command - you can define more helper functions (as static!) if you like.
You can also provide your own cleanup() function, see CLEANUP below.

To allow this modularity, we use custom ELF binary section to store cmd_desc
structures. These are created at compile-time and then simply traversed at
execution-time. This eliminates any need for registering your parse() function
in the main program body.

## CLEANUP ##

There's a cleanup functionality that *should* return the system and listening
server process to the original (new) state, as if the server was just started.
This implies killing any child processes and their process groups, removing any
temporary files, freeing any shared resources, etc.
It can be triggered by sending SIGHUP to the listening server process, which
executes all per-command cleanup() functions and then kills all its children
(and their process groups / further child processes) with SIGKILL.

VERY IMPORTANT facts about running cleanup:
- it is executed from a signal handler, which means that YOU CANNOT rely on ANY
  non-const variables modified after installing the signal handler (sigaction)
  because any such variables might be partially written (ie. 4 out of 8 bytes)
  when the signal/interrupt is received
  - an exception to this is "volatile sig_atomic_t" variable
- all per-command cleanup() functions are subject to signal handler limitations,
  meaning you can't use variables from normal command runtime
- all per-command cleanup() functions are executed from the listening server
  process (its signal handler), meaning any syscalls are executed as
  the listening server, NOT its command-parsing child, ie. getpid() returns
  the listening server pid, not the pid of the per-command parsing process
  - this - by extension - means that all cleanups need to be very generic and
    clean up everything possibly left by any number of instances of the same
    command from any number of clients - cleanups affect all connected clients
    - ie. 5 clients, each doing 'exec' cmd - the cleanup is executed only
      *once*, but it needs to kill all processes spawned by all 5 exec cmds
      from all clients

When to use cleanup?
You should really never need to use cleanup. The only exception is manual
intervention when you're ABSOLUTELY sure that the server isn't used by other
clients. You *cannot* run cleanup even with exclusive lock as that would
disconnect any legitimate clients waiting for unlock.

## LOCKING ##

Locking is a manual, voluntary action in this implementation (instead of
a mandatory one) for variety of reasons:

- flexibility - locking is a usecase-based logic, a client might lock the server
  and then initiate an arbitrary number of additional connections to it, before
  finishing the (complex/composite) task at hand and unlocking the server

- critical cleanup - when a connection doing (infinitely long) recv() dies,
  holding an exclusive lock, there's no way to remotely cleanup the server,
  because any attempt to connect will block on the lock

- mandatory blocking - the client can't check beforehand if the server is locked
  and voluntarily decide to give up - the server just grabs the connection and
  waits for the lock, with no notice, nothing

(and more).

Therefore it's up to the clients to lock the server with a shared lock on each
new connection, giving them the ability to do it the non-blocking way, with the
option to opt-out in critical situations.

Note that this locking implementation works around the reader bias inherent to
the underlying flock() backend and makes all readers and writers have equal
chance of getting the lock - see comments in cmds/lock.c.

## PERSISTENT SESSIONS ##

Normally, the (forked) server accepts only a single control command line, parses
it into commands and executes them, closing the connection and the session once
the cmdline ends.
The client can however request a "persistent" session which goes back to the
listening state when the cmdline ends, accepting and executing new commands,
still within the same process, sharing certain data between connections
(ie. stored cmd return values).

A client can request a persistent session creation by sending the (uppercase)
word SESSION, followed by a newline, to the server, in stead of the ctl cmdline.
The server replies with a number, followed by a newline, indicating the
listening port of the session. The client can then communicate on this port with
the server instead of the primary server process, sending commands in multiple
connections, optionally collecting command status:

$ s=$(nc <server> <port> <<<"SESSION")
$ nc <server> "$s" <<<"cmd1;cmd2"
$ nc <server> "$s" <<<"ctl-status"

The session can be ended either via the "ctl-end" command given within the
session itself (like "ctl-status" above) or externally using the
"ctl-kill,<sessionid>" command, which can be issued from a session-less cmdline
or another session.

Q: Don't multiple cmdlines break the binary data flow?
An earlier section described that the server won't ever read anything beyond
the first control cmdline, because it could interfere with commands transferring
binary data and it would be impossible for the server to distinguish where the
binary data end and cmdline begins.
This is not an issue here - we still allow only one cmdline *per connection*,
which is fine, because the binary flow ends with the connection.

# vim: syntax=off :

About

A simple TCP-based command server, useful for dependency-free deployments

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published