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.
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.
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.
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.
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
/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
/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
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
/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
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.
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.
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).