UUCP TLS Network — Setup Status

Three hosts, all links working, all six directions verified by checksum. Taylor UUCP 1.07, stunnel-terminated TLS, mutual certificate authentication. Managed by the ansible role roles/uucp; the playbook is idempotent.

Architecture

uucico never speaks to the network. It binds loopback only; stunnel terminates TLS and forwards to it. The UUCP password is a second factor inside an already-authenticated tunnel, not the security boundary.

Inbound: peer connects to TCP 10540, stunnel verifies the client certificate against the shared CA, forwards to 127.0.0.1:540, where systemd socket activation starts uucico per connection.

Outbound: uucico connects to a loopback port named in /etc/uucp/port, stunnel encrypts and forwards to peer:10540.

Port scheme

Uniform on every host, so a port number means the same thing everywhere.

UUCP port names follow the destination: port to-gravlax has service 5411 on every host. Inbound needs no port entry because uucico takes the accepted connection on stdin.

Why socket activation rather than uucico -e -p

Taylor UUCP has no bind-address option. A -p listener binds INADDR_ANY, which would expose a cleartext password-auth UUCP service to the network and defeat the point of the tunnel. systemd ListenStream=127.0.0.1:540 with Accept=yes binds loopback exactly. uucico with -l and no -p reads the connection from stdin, which is the classic inetd mode, so the FreeBSD equivalent is an ordinary inetd line.

Authentication

Per-direction logins and passwords, no shared secret. A caller authenticates as U<its own nodename>: pomelo logs into gravlax as Upomelo, kelp logs into gravlax as Ukelp.

Outbound credentials live in /etc/uucp/call, inbound in /etc/uucp/passwd, both 0600 uucp:uucp. The sys file uses call-login and call-password so no secret appears in a world-readable file.

Passwords are in ~/ansible/secrets.yml as uucp_pw_<caller>_to_<callee>, six of them. That file is gitignored and copied between controllers by scp.

TLS

One self-signed John Critchley CA signs everything. The UUCP sections live in each host's main /etc/stunnel/stunnel.conf alongside notes, postgres and socks, inheriting the global verify = 2 and CAfile.

kelp is the exception: its stunnel globals are its server identity, so its client sections override with kelp-client-cert.pem, kelp-client-key.pem and kelp-client-CA.pem. The role handles this through client_cert/client_key/client_cafile in the topology file.

stunnel connects by FQDN, not short name. Short names exist only in pomelo's /etc/hosts; gravlax and kelp cannot resolve each other otherwise.

Verified: connecting to a peer's 10540 without a client certificate gets a TLS certificate required alert and never reaches uucico. With the certificate, the login prompt appears.

Topology

gravlax (EC2 eu-north-1) and kelp (EC2 eu-central-1) are both callable and link to each other and to pomelo. Security groups for TCP 10540 are open to 0.0.0.0/0 on every link as of 2026-10-28 (John's explicit decision) — mutual TLS (stunnel verify = 2 against the shared CA) is the real gate, not source IP, and a per-peer /32 only goes stale the next time that peer's IP changes, which is exactly what caused the 2026-10-28 outage below. The uucp.yml firewall play (tags: firewall, never) now requests cidr 'any' for every rule instead of computing a /32 per peer.

pomelo is behind home NAT and cannot be called, so it is configured outbound-only: no [uucp] stunnel section, and uucico.socket installed but not enabled. Work queued on gravlax or kelp for pomelo is collected when pomelo calls, because UUCP turns the connection around mid-session. That is the normal polling model and needs no inbound reachability.

Gotchas worth remembering

/var/spool/uucppublic must be mode 1777. Taylor UUCP checks the destination's accessibility as the requesting user, not as uucp, so a 0700 public directory fails every inbound transfer with permission denied by remote on the caller and not permitted to receive on the receiver, even though the uucp user can write there. This is the most likely thing to break if someone tightens permissions later.

A transfer refused by the remote is dropped from the queue, so re-queue before retrying rather than expecting the old job to still be there.

The lock directory is compiled into policy.h as /usr/spool/uucp and cannot be set from the config file. All hosts are built the same way so they agree; changing it means rebuilding everywhere.

Taylor UUCP 1.07 is C89 from 2003 and will not build on Debian 13 / gcc 14 without -std=gnu89 and the implicit-declaration warnings turned back down. Its configure predates autoconf 2.50, so CFLAGS must be passed in the environment; as an argument it is read as a host type.

Validation runs uuchk as the uucp user via su -s /bin/sh, not as root. Root can read anything, so validating as root would not notice that uucp cannot read its own 0600 call and passwd files.

2026-10-14, two gotchas found testing a long-idle gravlax↔kelp leg (full detail and fix commands in uucp/radagast's Verification section): (1) stunnel caches DNS resolution for connect= targets for the life of the process and never re-resolves — if a peer's EC2 IP changes while stunnel keeps running, the symptom is ERROR: read: Connection reset by peer in the UUCP Log; fix is systemctl restart stunnel4 on the host dialing out. Hit independently on both gravlax (1 month uptime) and kelp (1.5 week uptime) in the same session. (2) Security groups for gravlax/kelp (see Topology above) also need updating when a peer's IP changes, not just DNS — this shows as a TCP connect TIMEOUT rather than a reset, so it's distinguishable from gotcha (1) even though both present as a failed UUCP call. aws/aws-open-port has the fix tool and a trap with its --flush option.

2026-10-28, a second-order instance of the same class of bug: kelp's security group for :10540 was found pinned to a stale /32 (pomelo's home IP from whenever that rule was last set, long since changed by the ISP), while every other port on the same SG was already 0.0.0.0/0. Same TCP connect TIMEOUT symptom as the gotcha above; confirmed with aws ec2 describe-security-groups rather than assuming a DNS problem. Per John's instruction, resolved as a policy change rather than a one-off re-point: every UUCP security-group rule mesh-wide is now 0.0.0.0/0 (see Topology above), since mutual TLS is the actual gate, not source IP.

uucp/mail — deployed internal mail, short addresses, configurable hub, polling, Ansible setup and host onboarding.

2026-10-28, a distinct gotcha from the permissions one above: none of the four hosts' sys files define local-send/local-receive/remote-send/remote-receive, so Taylor UUCP's compiled-in defaults applied everywhere — local-send unrestricted, but local-receive/remote-send/remote-receive all pubdir-only. Symptom: 'not permitted to receive' (receiver's Log) / 'permission denied by remote' (sender's Log) the moment a transfer targeted a path outside /var/spool/uucppublic, e.g. kelp pushing a file into pomelo's ~/.ssh. Per John's instruction ('allow anywhere to anywhere so long as file system permissions allow the uucp user'), all four lines are now set to / in sys.j2's defaults block (applied live via Ansible to gravlax/kelp) and hand-added to pomelo's and radagast's sys files directly — see uucp/radagast for the topology gap that prevents pomelo going through Ansible for this file. Taylor UUCP's own path policy is now a no-op mesh-wide; real Unix permissions on the uucp daemon user are the only remaining gate, confirmed by a receive into a 0700 human-owned directory still failing on real EACCES as intended.

Mail deployment 2026-09-20: inbound uucico@.service now uses Type=oneshot with -l -D -q and runs uuxqt in ExecStartPost, so received jobs finish before systemd closes the cgroup. command-path includes /usr/sbin for Postfix rmail. Polling is enabled only on pomelo, every 15 minutes to gravlax. New transport-role nodes default to non-callable.

version 5  ·  updated 2026-10-28