Cyb
                    irc transport daemon







#000 Index

#001 Introduction
#002 Architecture
#003 State machine
#004 Pipe interface
#005 Command protocol
#006 Irc message handling
#007 Security
#008 Configuration
#009 Operation
#010 Troubleshooting
#011 Configuration files


#001 Introduction

 cyb is the IRC transport layer for the cmech bot
 system. It manages a single IRC connection -- TCP
 establishment, optional TLS, protocol handshake,
 channel membership -- and bridges it to a pair of
 named pipes that cmech reads and writes.

 The system separates transport from logic at a hard
 boundary. cyb holds the socket. cmech holds none.
 This boundary is not a convenience; it is the
 security model. A crashed cmech does not drop the
 IRC session. A compromised cmech cannot reach the
 network. A crashed cyb does not corrupt cmech state
 -- cmech reopens the pipes and waits. Each process
 fails independently and recovers independently.

 cyb never exits on transient failures. Network drops,
 pipe breaks, and DNS failures all trigger repair
 transitions. Only an explicit quit command stops the
 machine. All network errors (including TLS certificate
 failures) trigger automatic reconnect.

 This reflects a deliberate decision: a bot that
 silently exits on a flaky network or expectable pipe
 errors is less useful than one that stays running
 and reconnects try cycle of check if exists/open/
 read/create.

 Named pipes were chosen over sockets for IPC because
 they are filesystem objects. [std:posix.1-2017] They
 can be inspected, written to, and read from with
 standard tools. An operator can inject a raw IRC
 command with echo. A script can read bot output with
 cat. No custom tooling is required to interact with a
 running instance. [doc:cmech.txt]


#002 Architecture

cyb is a single-threaded state machine driven by a
tight loop. Every iteration calls cyb_do_work(), which
performs I/O for the current state and writes results
into the _cybnet struct, then calls cyb_transitions(),
which reads those results and advances the state. The
separation is deliberate: cyb_do_work does I/O, never
state transitions. cyb_transitions changes state, never
I/O. The loop has no hidden coupling between the two.

Call flow:
 ___________________________________________________________
 main()
   |
   v
 cyb_start()
   |
   v
 while state != STOP <------------------------------.
   |                                                 |
   v                                                 |
 cyb_do_work()                                       |
   |                                                 |
   +-- INIT    -> cyb_init()                         |
   |                                                 |
   +-- CONNECT -> cyb_connect()                      |
   |                                                 |
   +-- CONNECTED -> cyb_connected()                  |
   |                                                 |
   +-- JOIN    -> cyb_join()                         |
   |                                                 |
   +-- WORK    -> select()                           |
   |               |                                 |
   |               +-- net fd readable               |
   |               |     -> cyb_net()                |
   |               |                                 |
   |               +-- pipe fd readable              |
   |                     -> cyb_input()              |
   |                          |                      |
   |                          +-- dispatch to        |
   |                               cmd_handlers[]    |
   |                                                 |
   +-- STOP    -> (no-op)                            |
   |                                                 |
   v                                                 |
 cyb_transitions()                                   |
   reads net_error, pipe_error, got_001,             |
   joined, quit, timeout, last_ping                  |
   advances state                                    |
   '------------------------------------------------'
 ___________________________________________________________

cyb_start() initialises all result fields to zero and
enters the loop. On exit from the loop it closes both
pipe fds and calls net_close().

_cybnet is the single state struct. All state lives
here. No global mutable state exists outside progname
and version.


#003 State machine

Five states plus STOP. Transitions are driven by
result fields written by cyb_do_work and read by
cyb_transitions. No transition happens inside
cyb_do_work.

 ___________________________________________________________
 INIT
   cyb_init() checks both pipe fds with fstat().
   A broken or missing pipe is closed and reopened.
   The output pipe is opened O_RDWR if no reader is
   present yet (ENXIO), keeping the pipe alive without
   blocking. Sets pipe_error=1 on failure.

   On success (pipe_error==0):
     if joined==1  -> WORK   (pipes recovered mid-session)
     if got_001==1 -> JOIN   (pipes recovered post-welcome)
     else          -> CONNECT

   On failure: stays INIT, retries next iteration.

 CONNECT
   cyb_connect() calls net_close() to clear any stale
   connection, then net_dial() for a fresh TCP socket.
   If TLS is enabled, net_dial_tls() negotiates TLS and
   verifies the certificate against /etc/ssl/cert.pem.
   Sends PASS (if configured), NICK, and USER to begin
   the IRC handshake. Sets net_error=0 on success,
   -1 on general failure.
   Respects the reconnect timer: cyb_do_work skips the
   connect attempt if reconnect > now.

   net_error != 0   -> arms reconnect timer, stays
                        CONNECT
   reconnect <= now -> CONNECTED

 CONNECTED
   cyb_connected() reads from the network in a loop,
   parsing IRC messages with msg_irc_next(). Responds
   to PING with PONG. Sets got_001=1 on receipt of the
   001 welcome numeric. Sets net_error=-1 on read
   failure.

   net_error != 0  -> CONNECT
   got_001 == 1    -> JOIN

 JOIN
   cyb_join() sends a JOIN command for each channel
   in conf.channels (comma-separated). Sets the first
   channel as current_channel if none is set. Sets
   joined=1 on completion, net_error=-1 on write
   failure.

   net_error != 0  -> CONNECT
   joined == 1     -> WORK

 WORK
   cyb_do_work() calls select() with SELECT_TIMEOUT_US
   (50ms) on the network fd and the input pipe fd. On
   each readable fd it calls the corresponding handler
   and stores the return value in net_error or
   pipe_error.

   quit == 1       -> STOP
   pipe_error == 1 -> INIT
   net_error != 0  -> CONNECT
   inactivity > TIMEOUT_NETWORK (300s) and
     last_ping set and
     now - last_ping > TIMEOUT_PONG (30s) -> CONNECT
   inactivity > TIMEOUT_NETWORK and
     last_ping not set -> send PING, set last_ping

 STOP
   cyb_do_work is a no-op. cyb_transitions is a no-op.
   cyb_start() exits the loop, closes fds, returns
   EX_OK.
 ___________________________________________________________

Illegal transitions: no state transitions to INIT
except from WORK on pipe_error. STOP is terminal.
CONNECTED can only be reached from CONNECT.


#004 Pipe interface

cyb communicates with cmech via two named pipes.
[std:posix.1-2017]

 ___________________________________________________________
 in_path   (~/.cmech/nick.in)    cyb reads commands
 out_path  (~/.cmech/nick.out)   cyb writes IRC output
 ___________________________________________________________

The naming is from cyb's perspective. cmech writes to
nick.in (cyb's input) and reads from nick.out (cyb's
output).

The input pipe is opened O_RDONLY | O_NONBLOCK. The
output pipe is opened O_WRONLY | O_NONBLOCK. If no
reader is present on the output pipe at open time,
open() returns ENXIO; cyb retries with O_RDWR to hold
the pipe open without blocking.

On EOF from the input pipe (read returns 0), cyb closes
the fd and sets pipe_error=1, triggering an INIT
transition to reopen. This handles cmech restarts
without requiring cyb to restart.

cyb writes one complete IRC message per write() call
to out_path. Lines are newline-terminated. cmech reads
them with read() and parses with msg_irc().

cyb reads commands from in_path with read(), parses
with msg_pipe_next(), and dispatches via the command
handler table.


#005 Command protocol

Commands on the input pipe use a single-character
prefix followed by optional arguments. The prefix
character is CMD_PREFIX (':' by default, defined in
config.h). Lines without the prefix are sent as
PRIVMSG to the current channel.

 ___________________________________________________________
 Command  Arguments             Effect
 ___________________________________________________________
 :j       #channel              JOIN channel
 :l       [#channel] [reason]   PART channel
 :m       target message        PRIVMSG target :message
 :s       [#channel]            set current channel
 :r       raw IRC text          send raw IRC command
 :c       var value             set config at runtime
 :g       [var]                 print config to pipe
 :x       start|stop            start or stop connection
 :q       [message]             QUIT and set quit=1
 :h                             print command list
 ___________________________________________________________

:c accepts: server, port, nick, channels. A nick
change while in WORK state immediately sends NICK to
the server.

:x stop sends QUIT to the server and sets quit=1,
which transitions to STOP. :x start clears quit and
resets timers, transitioning from STOP back to CONNECT
on the next tick. This allows restarting the IRC
connection without restarting the process.

:q sends QUIT with an optional message and sets
quit=1. This differs from :x stop only in that it
always sends a QUIT message regardless of state.

Lines without a ':' prefix are passed through as
PRIVMSG to conf.current_channel. Lines with an
unrecognised prefix character are forwarded raw to
the IRC server.


#006 Irc message handling

In WORK state, cyb_net() reads from the network fd
and processes each newline-terminated IRC message.

PING    Responds immediately with PONG. Resets
        last_ping to 0 so the keepalive timer arms
        again on the next inactivity window.

PONG    Resets last_ping to 0. Clears the PONG
        timeout condition.

NICK    If the nick in the message matches conf.nick,
        updates conf.nick to the new value. Handles
        server-enforced nick changes.

PRIVMSG Forwards the complete raw IRC line to the
        output pipe with a trailing newline. cmech
        receives the full line and parses it with
        msg_irc().

All other messages are read and discarded in WORK
state. In CONNECTED state, only PING and 001 are
handled.


#007 Security

cyb applies OpenBSD privilege restriction after pipe
and path setup in main(). [man:unveil(2)] [man:pledge(2)]

unveil(2) scope:
  in_path   rwc
  out_path  rwc
  /etc/ssl/cert.pem  r  (SSL only)

pledge(2) scope:
  stdio rpath wpath cpath inet dns

inet and dns are required for net_dial() and
net_ssl(). exec is not pledged -- cyb cannot fork or
exec any process. A compromised cyb cannot run
arbitrary code on the host.

TLS certificate verification is mandatory. [std:rfc8446]
SSL_CTX_set_verify uses SSL_VERIFY_PEER. A certificate
failure (net_error==-1) transitions to
CONNECT.

The named pipe interface is the attack surface for
command injection. Any process that can write to
in_path can send arbitrary IRC commands through cyb.
Pipe permissions are 0600, owner-only. [std:posix.1-2017]
cmech writes to in_path; no other process should.


#008 Configuration

All configuration is set at startup via command-line
flags. Runtime changes via :c do not persist across
restarts.

 ___________________________________________________________
 Flag  Default               Description
 ___________________________________________________________
 -s    irc.sophia.host       IRC server hostname
 -p    6697                  server port
 -n    $USER                 bot nick
 -k    (none)                server password (PASS)
 -j    #main                 channels to join on connect
 -i    ~/.cmech/nick.in      input pipe path
 -o    ~/.cmech/nick.out     output pipe path
 -c    (SSL enabled)         disable SSL/TLS
 -d    (off)                 debug output to stderr
 -v    (off)                 print version and exit
 -h    (off)                 print usage and exit
 ___________________________________________________________

The pipe paths are derived from $HOME and the nick if
not given explicitly. The directory ~/.cmech/ is
created with mode 0700 if absent. The pipes are
created with mkfifo(2) mode 0600 if absent. An
existing pipe at the path is reused. [std:posix.1-2017]

compile-time constants in config.h:
 ___________________________________________________________
 SELECT_TIMEOUT_US   50000   select timeout (50ms)
 TIMEOUT_NETWORK     300     inactivity before PING (s)
 TIMEOUT_PONG        30      PONG wait before reconnect
 TIMEOUT_RECONNECT   5       delay between reconnects (s)
 BUFSIZE             4096    read/write buffer size
 NICKLEN             32      maximum nick length
 CHANLEN             256     maximum channel name length
 ___________________________________________________________

When running as a system service, the rc.d script uses
`doas` to drop privileges to the `_cmech` user.  The
following rules must exist in /etc/doas.conf:
 ___________________________________________________________
 permit nopass root as _cmech cmd /usr/local/bin/cyb
 permit nopass root as _cmech cmd /usr/local/bin/cmech
 ___________________________________________________________


#009 Operation

Start cyb before cmech. cyb creates the pipes; cmech
opens them.
 ___________________________________________________________
 $ ./cyb -n cyb -j "#main"
 ___________________________________________________________

With debug output:
 ___________________________________________________________
 $ ./cyb -d -n cyb -j "#main"
 cyb: connecting to irc.sophia.host:6697
 STATE: INIT -> CONNECT
 STATE: CONNECT -> CONNECTED
 NET << NICK cyb
 NET << USER cyb localhost irc.sophia.host :cyb
 NET >> 001 cyb :Welcome to the IRC Network cyb
 STATE: CONNECTED -> JOIN (got 001)
 NET << JOIN #main
 STATE: JOIN -> WORK
 ___________________________________________________________

Send a message from the shell:
 ___________________________________________________________
 $ echo ":m #main hello" > ~/.cmech/cyb.in
 ___________________________________________________________

Read recent output:
 ___________________________________________________________
 $ cat ~/.cmech/cyb.out
 ___________________________________________________________

Quit cleanly:
 ___________________________________________________________
 $ echo ":q Goodbye" > ~/.cmech/cyb.in
 ___________________________________________________________


#010 Troubleshooting

cyb connects but no output reaches cmech:

  Verify the output pipe has a reader. If cmech is not
  running, cyb holds the output pipe open with O_RDWR
  and writes succeed silently but cmech never reads
  them. Start cmech.
 ___________________________________________________________
 $ ls -la ~/.cmech/
 prw-------  1 punk  punk  0 cyb.in
 prw-------  1 punk  punk  0 cyb.out
 ___________________________________________________________

cyb reconnects in a loop and never reaches WORK:

  This could be due to persistent TLS certificate
  verification failures (check debug output with -d). The
  bot will retry every TIMEOUT_RECONNECT seconds. If the
  certificate is permanently invalid, consider using -c to
  disable TLS (not recommended for production) or update the
  CA bundle.

  A loop on CONNECT indicates the server is unreachable,
  rejecting the nick/user registration, network certificates,
  or any other network related problem before it can
  communicate with remote server.
 ___________________________________________________________
 $ ./cyb -d -n cyb 2>&1 | head -20
 ___________________________________________________________

cyb transitions to STOP immediately:

 The only way cyb reaches STOP is via explicit :x stop or
 :q commands from the input and / or input pipe or when quit
 flag is set).

Commands sent to in_path are ignored:

  Verify the pipe is open and cyb is in WORK state.
  In INIT, CONNECT, CONNECTED, and JOIN states, cyb
  does not read from the input pipe.
 ___________________________________________________________
 $ echo ":g state" > ~/.cmech/cyb.in && cat ~/.cmech/cyb.out
 state: 5
 ___________________________________________________________

  State 5 is WORK. See enum _state in cyb.h.

Nick rejected by server (433 in debug output):

  The nick is already in use. Set a different nick:
 ___________________________________________________________
 $ echo ":c nick cyb_" > ~/.cmech/cyb.in
 ___________________________________________________________


#011 Configuration files

~/.cmech/cyb.in
~/.cmech/cyb.out
/etc/ssl/cert.pem



                         *   *   *


[doc:cmech.txt]       gopher://sophia.host/0/dev/tools/cmech/cmech.txt
[std:posix.1-2017]    IEEE Std 1003.1-2017, Shell and
                      Utilities volume
[man:unveil(2)]       Restrict filesystem access
[man:pledge(2)]       Restrict system call access
[std:rfc8446]         The Transport Layer Security (TLS)
                      Protocol Version 1.3
[man:ssl_ctx_set_verify(3)] Peer certificate verification

____________________________________________________________
rev-1.0.1
gopher://sophia.host/0/dev/tools/cmech/cyb.txt
