UUCP — radagast (virtio-serial link)

Parent: uucp

radagast is a FreeBSD 15.1 KVM guest on pomelo. It has no network-callable IP, so it uses two virtio-serial devices exposed as PTYs on the host. Link 1 (pomelo→radagast): pomelo calls as master. Link 2 (radagast→pomelo): radagast calls as master. Mail flows bidirectionally; Internet-bound outbound mail remains gated off.

Link 1: pomelo→radagast (inbound)

QEMU exposes a virtio-serial port to the guest named org.uucp backed by a Unix socket on pomelo:

-chardev socket,id=uucp,path=/var/run/radagast-uucp.sock,server=on,wait=off
-device virtio-serial-pci,id=virtserial0
-device virtserialport,bus=virtserial0.0,chardev=uucp,name=org.uucp

Inside the guest the device appears as /dev/ttyV0.1. A systemd service on pomelo bridges the Unix socket to a PTY that pomelo's UUCP uses as a direct serial device:

# /etc/systemd/system/radagast-serial.service
[Service]
Type=simple
ExecStartPre=/bin/rm -f /run/radagast-tty
ExecStart=/usr/bin/socat -d PTY,rawer,link=/run/radagast-tty,mode=0666 \
    UNIX-CONNECT:/var/run/radagast-uucp.sock,retry=20,interval=3
Restart=always
RestartSec=5

The PTY is symlinked as /run/radagast-tty. The service restarts automatically; QEMU must be running first (it creates the Unix socket). socat's retry=20,interval=3 waits up to 60 s for QEMU to appear.

Link 2: radagast→pomelo (outbound)

A second virtio-serial port (org.uucp.r, guest device /dev/ttyV0.2) lets radagast call pomelo as UUCP master. pomelo runs uucico -l as the answering side.

# additional args in start-radagast-fast.sh (alongside existing uucp port):
-chardev socket,id=uucp2,path=/var/run/radagast-uucp2.sock,server=on,wait=off
-device virtserialport,bus=virtserial0.0,chardev=uucp2,name=org.uucp.r

radagast-serial2.service bridges /var/run/radagast-uucp2.sock to PTY /run/radagast-tty2 (same pattern as radagast-serial.service). The answering side uses uucico -l directly — not agetty. agetty 2.39.3 prepends /dev/ to absolute PTY paths, giving /dev//run/radagast-tty2 which cannot be opened. radagast-uucp-answer.service:

# /etc/systemd/system/radagast-uucp-answer.service
[Unit]
Description=UUCP answer service for radagast inbound calls
After=radagast-serial2.service
Requires=radagast-serial2.service

[Service]
Type=simple
ExecStartPre=/bin/bash -c 'until [ -L /run/radagast-tty2 ]; do sleep 1; done'
ExecStart=/bin/bash -c 'stty -F /run/radagast-tty2 115200 raw -echo 2>/dev/null; exec /usr/local/sbin/uucico -l -D -q < /run/radagast-tty2 > /run/radagast-tty2'
Restart=always
RestartSec=2
User=uucp

Uradagast is the login user on pomelo that radagast authenticates as when calling in. Password in /etc/uucp/passwd (pomelo) and /usr/local/etc/uucp/call (radagast). Same pattern as Upomelo on radagast.

# on pomelo:
useradd -r -s /usr/local/sbin/uucico -d /var/spool/uucp -g uucp Uradagast
# set password to match /etc/uucp/passwd entry 'Uradagast <secret>'

/dev/ttyV0.2 is 0600 root:wheel by default. uucico and uux are setuid uucp but setgid dialer — the effective GID when opening the port is dialer, not uucp. Add /etc/devfs.rules on radagast with group dialer for ttyV0.2 (group uucp fails) and set devfs_system_ruleset="localrules" in /etc/rc.conf:

[localrules=10]
add path ttyV0.1 mode 0660 group uucp
add path ttyV0.2 mode 0660 group dialer

/usr/local/etc/uucp/call must be 0640 (group uucp), not 0600. With 0600, uucico running as the uucp user cannot read it and logs only No login defined. The port config uses /dev/ttyV0.2 directly; ln -sf /dev/ttyV0.2 /dev/ttyuucp1 in rc.local adds a named symlink.

Getty on radagast — why and how

Early tests used an empty UUCP chat string (uucico slave mode, no login). This failed when stale binary data remained in the virtio channel from a previous aborted call — the next handshake saw garbage and both sides timed out.

The fix is to run getty on the serial line. pomelo's login-chat sends newlines to drain any leftover data, then authenticates through the normal login prompt before handing off to uucico.

FreeBSD init cannot spawn getty on a device whose name contains a dot. Because the kernel names the device ttyV0.1 a symlink is required:

# on radagast, done once:
ln -sf /dev/ttyV0.1 /dev/ttyuucp0

In /etc/ttys on radagast:

ttyuucp0  "/usr/libexec/getty 3wire.115200"  dialup  on secure

The 3wire type (RTS/CTS-free) matches the virtual device. After each call, init respawns getty automatically, so the line is always ready for the next poll.

Login user on radagast

pomelo authenticates as Upomelo. That user's shell is /usr/local/libexec/uucp/uucico so login drops straight into the UUCP handshake. The password is in pomelo's /etc/uucp/call and not recorded here.

# creation:
pw useradd Upomelo -s /usr/local/libexec/uucp/uucico -d /var/spool/uucp -g uucp

pomelo UUCP config

/etc/uucp/sys — radagast stanza:

system radagast
time any
port to-radagast
chat "" \n\n\n ogin: \L word: \P
call-login *
call-password *
called-login Uradagast

The chat string sends three newlines to flush any pending data before waiting for the login prompt. Credentials are resolved from /etc/uucp/call (0600, not shown here).

/etc/uucp/port — radagast port:

port to-radagast
type direct
device /run/radagast-tty
speed 115200

radagast UUCP config

/usr/local/etc/uucp/sys on radagast — pomelo stanza:

system pomelo
time any
port to-pomelo
chat "" \n\n\n ogin: \L word: \P
call-login *
call-password *
called-login Upomelo

called-login Upomelo authenticates inbound calls from pomelo on link 1. Outbound calls (link 2) use credentials in /usr/local/etc/uucp/call (must be 0640 so the uucp group can read it).

/etc/hosts on radagast — pomelo must be resolvable (no DNS in the NAT guest):

10.0.2.2  pomelo  pomelo.critchley.biz

Polling schedule (radagast cron)

Both Postfix transports omit -r from uux, so uucico is triggered immediately on enqueue in either direction. The 15-minute forced-poll (-f) is a backstop that also collects mail queued at pomelo when radagast has nothing to send.

# crontab -u uucp on radagast
0,15,30,45 * * * * /usr/local/libexec/uucp/uucico -s pomelo -f -D -q 2>/dev/null; /usr/local/libexec/uucp/uuxqt -s pomelo 2>/dev/null

Mail — Postfix on pomelo

/etc/postfix/uucp_transport includes a radagast line alongside the existing gravlax/kelp entries:

/(^|@)radagast$/  uucp-mail-now:radagast

The existing uucp-mail transport in master.cf pipes through uux:

uux -r -n -z -a${sender} - ${nexthop}!rmail (${recipient})

After editing the transport table, run postmap /etc/postfix/uucp_transport && postfix reload.

mydestination must include pomelo.critchley.biz. rmail from radagast delivers to john@pomelo.critchley.biz; without the FQDN in mydestination Postfix routes it to the external MX.

postconf -e 'mydestination = pomelo, localhost, pomelo.critchley.biz'
postfix reload

Mail — Postfix on radagast

radagast uses Postfix (mail/postfix from ports, installed 2026-10-14). The setup below superseded an earlier Postfix config documented here on 2026-10-03 — that one did not survive the 13.4→15.1 source upgrade (Postfix was gone, sendmail's mailer.conf pointed at a nonexistent binary, and outbound was still gated in rc.conf). Rebuilt from scratch 2026-10-14 and fully retested.

# appended to /usr/local/etc/postfix/main.cf
myhostname = radagast.critchley.biz
mydomain = critchley.biz
myorigin = radagast
mydestination = radagast, localhost, radagast.critchley.biz
append_dot_mydomain = no
allow_percent_hack = yes
swap_bangpath = yes
default_transport = uucp-mail:pomelo
local_transport = local
mynetworks = 127.0.0.0/8 [::1]/128

No transport map file is needed. radagast only has one UUCP neighbour (pomelo), so default_transport = uucp-mail:pomelo above routes everything non-local there — including percent-hack addresses like john%kelp@pomelo (the percent-hacked local part doesn't affect the domain match, so no per-destination-host rule is ever needed on radagast; pomelo's own transport map resolves the rest). This is simpler than the file-based hash:/usr/local/etc/postfix/transport approach from the original 2026-10-03 design, and avoids a bug class that bit the hub hosts: an exact-match transport table keyed on the FQDN silently fails to match a bare short address (or vice versa).

Append a UUCP pipe mailer to /usr/local/etc/postfix/master.cf — named uucp-mail to match pomelo's naming (the default install's commented-out uucp mailer stanza is not used):

# appended to /usr/local/etc/postfix/master.cf
uucp-mail unix  -       n       n       -       -       pipe
  flags=F user=uucp argv=/usr/local/bin/uux -n -z -a${sender} - ${nexthop}!rmail (${recipient})

Full remaining steps, in order:

# /etc/rc.conf
sendmail_enable="NONE"
sendmail_submit_enable="NO"
sendmail_outbound_enable="NO"
sendmail_msp_queue_enable="NO"
postfix_enable="YES"

# /etc/mail/mailer.conf — point every entry at Postfix's own binaries
sendmail	/usr/local/sbin/sendmail
send-mail	/usr/local/sbin/sendmail
mailq	/usr/local/bin/mailq
newaliases	/usr/local/bin/newaliases

# root's local mail should land in john's mailbox
echo 'root: john' >> /etc/aliases && /usr/local/bin/newaliases

/usr/local/sbin/postfix check
/usr/local/sbin/postfix set-permissions
service postfix start

Gotcha that cost the most time (2026-10-14): FreeBSD's base mailwrapper routes a direct sendmail CLI invocation to /etc/mail/mailer.conf's sendmail entry — but even after pointing that at Postfix's binary, test sends failed with Connecting to [127.0.0.1] via relay... Connection refused. That is Postfix's own split: sendmail invoked directly (not through postfix start's daemon) still goes through the sendmail compatibility wrapper, which is fine for Postfix — unlike base sendmail there is no separate submit.cf to fight. The actual cause was leftover base-sendmail state: /etc/mail/mailer.conf pointed at a nonexistent ports path, and before Postfix was installed, base sendmail's own /etc/mail/submit.cf (a simplified config that just relays to localhost:25, used by direct CLI invocation instead of the full sendmail.cf) was silently in the loop. Installing Postfix and repointing mailer.conf at its binaries removes submit.cf from the path entirely — the fix above already does this. If this resurfaces on a sendmail-based (not Postfix) host, the fix is to copy sendmail.cf over submit.cf so direct CLI sends use the real routing rules instead of the stripped-down submission config.

Mail flow and gating

Link 1 (pomelo→radagast): Postfix uucp-mail-now transport → uux → UUCP call → radagast uucico → uuxqt → rmail (/usr/local/bin/rmail, Postfix's own, installed with the package) → local delivery.
Link 2 (radagast→pomelo): Postfix default_transport → uucp-mail pipe → uux → UUCP call → pomelo uucico -l → uuxqt → rmail → Postfix local delivery (or further routing if percent-hacked, e.g. john%kelp@pomelo).

Internet-outbound mail from radagast is still not configured and must not be enabled without explicit approval — this is about the internal mesh only. Outbound within the mesh (radagast↔pomelo, and from there to gravlax/kelp) was explicitly approved by John 2026-10-14, after being held off since 2026-09-18 pending that go-ahead. See uucp/mail for the addressing scheme (short host-qualified addresses, percent-hack for multi-hop). Internal mail to other UUCP nodes (e.g. pomelo→gravlax→kelp) routes via pomelo as the entry point, then gravlax as hub, exactly as for any other spoke — radagast is not special-cased beyond having only one physical neighbour.

Future work

Verification (2026-10-14)

All four directions tested end-to-end, confirmed by Received-header chains in the destination mailbox (not just "queue empty" — that only proves the local pipe handed off to uux, not that the UUCP call happened; see the gotcha below).

# 1. radagast -> pomelo direct
ssh radagast "echo 'test' | /usr/local/sbin/sendmail john@pomelo"
# 2. pomelo -> radagast direct
echo 'test' | /usr/sbin/sendmail john@radagast
# 3. radagast -> kelp via pomelo (forward)
ssh radagast "echo 'test' | /usr/local/sbin/sendmail 'john%kelp@pomelo'"
# 4. kelp -> radagast via pomelo (reverse)
ssh kelp "echo 'test' | /usr/sbin/sendmail 'john%radagast@pomelo'"

# On-demand transports (uux without -r) fire the UUCP call automatically on
# enqueue, so usually nothing further is needed. If a hop stalls (postqueue -p
# and uustat -a both empty, nothing in the next hop's log either), force it:
sudo uucico -s <nexthop> -f -D -q      # on the host that should be calling
sudo /usr/local/sbin/uuxqt             # execute any job that already arrived

Two unrelated infrastructure bugs surfaced only while testing hop 3/4 (gravlax↔kelp, pre-existing links that happened not to have been exercised in a while) — both are general mesh gotchas, not radagast-specific, and are recorded in full in uucp/setup-status and aws/aws-open-port. Summary of the fix commands used:

# Bug A: stunnel's cached DNS resolution went stale after a peer's EC2 IP
# changed (symptom: "ERROR: read: Connection reset by peer" in the UUCP Log)
sudo systemctl restart stunnel4           # on the host doing the outbound dialing

# Bug B: AWS security group only allowed the peer's OLD IP on :10540
# (symptom: TCP connect TIMEOUT, not a reset -- distinct from Bug A)
python3 ~/aws/aws_open_port.py gravlax 10540 kelp --region eu-north-1
python3 ~/aws/aws_open_port.py kelp 10540 gravlax --region eu-central-1
# NOTE: --flush removes ALL existing rules for that port first, including
# other legitimate peers (pomelo's own rule on gravlax got wiped this way
# and had to be re-added separately, without --flush, straight after).
created 2026-10-03  ·  updated 2026-10-28  ·  tags uucp, radagast, freebsd, qemu, serial  ·  version 9