Using WireGuard Keys for SSH

The best practice for managing SSH keys is to use SSH CA host and user certificates. However, if you’re already using WireGuard to connect two machines in a point-to-point network, you can skip SSH key management entirely, and simply rely on WireGuard for authentication. This article will show you how.

This concept works because WireGuard provides an encrypted connection with mutual authentication, just like SSH. In a point-to-point configuration between two endpoints, like the following between Endpoint A and Endpoint B, each WireGuard endpoint has been configured with the other’s public key, and uses that key to cryptographically authenticate the other side of the connection:

Public keys configured for corresponding endpoints
Figure 1. Mutual authentication with WireGuard

Furthermore, WireGuard’s cryptokey routing automatically blocks traffic from flowing through the WireGuard tunnel unless you allow-list it by peer and IP address. Therefore, if we configure our WireGuard interface on Endpoint B to accept connections from Endpoint A with traffic from 10.0.0.1, WireGuard guarantees us that any packet which emerges from that interface with a source address of 10.0.0.1 legitimately has been sent to us from Endpoint A (or from a host that controls Endpoint A’s private key):

Routing rules bound to cryptographic identities
Figure 2. Cryptokey routing enforced by WireGuard

So if we configure our WireGuard interface on Endpoint B to accept connections from Endpoint A that have a source address of 10.0.0.1 — and configure the firewall on Endpoint B to drop connections that have a source address of 10.0.0.1 from anywhere but this interface — each network service we run on Endpoint B can trust that any connection to it with a source address of 10.0.0.1 legitimately came from Endpoint A.

And if we trust that an individual user (like Alice) has sole control of Endpoint A’s WireGuard private key, then we can automatically log her into any network service (like SSH) on Endpoint B whenever the connection has a source address of 10.0.0.1:

WireGuard tunnel from Endpoint A to Endpoint B
Figure 3. Alice’s SSH connection through a point-to-point WireGuard tunnel

To do this for SSH, these are the components we need to configure on Endpoint B:

With this configuration in place on Endpoint B, we can then Test It Out from Endpoint A (and do any Troubleshooting necessary). As a bonus, at the end of this article we’ll also show how we could ditch SSH entirely, replacing it with Telnet and FTP (or RSH) — encrypted and authenticated by WireGuard.

WireGuard Configuration

First, we’ll set up WireGuard on Endpoint A and Endpoint B just like the WireGuard Point to Point Configuration article (refer to it for a detailed explanation of each config setting).

On Endpoint A, we’ll use the following WireGuard config:

# /etc/wireguard/wg0.conf

# local settings for Endpoint A
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1/32
ListenPort = 51821

# remote settings for Endpoint B
[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
Endpoint = 203.0.113.2:51822
AllowedIPs = 10.0.0.2/32

This configuration means that Endpoint A will use a source IP address of 10.0.0.1 for its connections to Endpoint B, and Endpoint A will accept only connections with a source IP address of 10.0.0.2 from Endpoint B.

On Endpoint B, we’ll use the following WireGuard config:

# /etc/wireguard/wg0.conf

# local settings for Endpoint B
[Interface]
PrivateKey = ABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBFA=
Address = 10.0.0.2/32
ListenPort = 51822

# remote settings for Endpoint A
[Peer]
PublicKey = /TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=
AllowedIPs = 10.0.0.1/32

This configuration means that Endpoint B will use a source IP address of 10.0.0.2 for its connections to Endpoint A, and Endpoint B will accept only connections with a source IP address of 10.0.0.1 from Endpoint A.

SSHD Configuration

Since we’ll use PAM to accomplish authentication (covered a few sections below), we need to enable it in our sshd_config file on Endpoint B. To enable PAM for authentication, we need to make sure the (Linux-specific) UsePAM setting is yes — plus either KbdInteractiveAuthentication or PasswordAuthentication is also set to yes:

# /etc/ssh/sshd_config
...
UsePAM yes
KbdInteractiveAuthentication yes
# or PasswordAuthentication yes
Note

The UsePAM setting by itself does not enable PAM-based authentication — it enables only the use of PAM for session set-up and account provisioning once the user has already been authenticated. To enable PAM-based authentication, you must also enable either the KbdInteractiveAuthentication or PasswordAuthentication settings.

Tip

If you already use PAM-based SSH authentication on Endpoint B, you will already have the above settings in your sshd_config file, so you will not need to make any changes to your SSH configuration.

If you don’t use PAM for SSH authentication, and use only public-key authentication for SSH on Endpoint B, you can further harden your sshd_config file by making the following adjustments:

  1. Add all the users who will use SSH public-key authentication to a group dedicated for that purpose (eg ssh-pk-users):

    $ sudo groupadd ssh-pk-users
    $ sudo usermod --append --groups ssh-pk-users cate
    $ sudo usermod --append --groups ssh-pk-users dan
  2. Modify your sshd_config file to by default allow only this group to log in, and only with public-key authentication; then add a Match section at the end of the config to relax this restriction for access from your WireGuard network (eg 10.0.0.0/24):

    # /etc/ssh/sshd_config
    ...
    AllowGroups ssh-pk-users
    KbdInteractiveAuthentication no
    PasswordAuthentication no
    UsePAM yes
    
    Match Address 10.0.0.0/24
        AllowGroups *
        KbdInteractiveAuthentication yes

This will ensure that:

  1. User accounts authorized for WireGuard public-key authentication may do so.

  2. User accounts authorized for SSH public-key authentication may do so — but only through SSH public-key authentication (not via password or any other PAM authentication mechanisms).

  3. An adversary who breaks into any other unprivileged user or service account cannot configure that account to enable SSH access (eg by setting a password for the account, or dropping an authorized_keys file into the account’s home folder, etc).

We can double-check that our sshd_config file is valid by running the sshd -t command (no output means OK), and then reload it with our changes:

$ sudo sshd -t
$ sudo systemctl reload sshd

Netgroup Configuration

Netgroups are an old-school way of associating a local user account with a remote host. They’re completely distinct from regular account groups. We’ll use netgroups to connect local user accounts with remote WireGuard IP addresses — such as to connect the alice user account on Endpoint B with Endpoint A’s WireGuard IP address of 10.0.0.1.

First, we’ll create an /etc/netgroup file on Endpoint B, and add an entry for each local user account on Endpoint B that we want to associate with a remote WireGuard address; plus we’ll add a master wg-users entry that combines each per-user netgroup into a larger group for all WireGuard users:

# /etc/netgroup
wg-alice (10.0.0.1,alice,)
wg-bob (10.0.0.3,bob,)
wg-users wg-alice wg-bob

Each entry in the /etc/netgroup file specifies a separate netgroup; each entry consists of the netgroup name listed first (eg wg-alice), followed the members the netgroup (eg the (10.0.0.1,alice,) combination), separated by spaces. Members of a netgroup may be a (host,user,domain) combination, or they may be other named netgroups.

We’ll leave the domain field blank when we specify our netgroups. Our wg-alice netgroup combines the WireGuard IP address 10.0.0.1 (Endpoint A) with the alice user account; our wg-bob netgroup combines the WireGuard IP address 10.0.0.3 (some other host) with the bob user account; and our wg-users netgroup includes both combinations ((10.0.0.1,alice,) and (10.0.0.3,bob,)).

After creating the /etc/netgroup file as root, make sure to make it readable (but not writeable) to other users:

$ sudo chmod 644 /etc/netgroup

Once we’ve defined our netgroups in the /etc/netgroup file, we’ll configure them to be used by editing the /etc/nsswitch.conf file. Edit it, and change its netgroup entry to include files (if it isn’t already using files as one of its netgroup data sources):

# /etc/nsswitch.conf
...
netgroup:       files
Note
Traditionally, netgroups were often configured via a NIS (Network Information Service) server; but in more modern times they may also be configured via LDAP. If you do actually use netgroups with NIS or LDAP, don’t edit your /etc/netgroup and /etc/nsswitch.conf files — use your existing NIS or LDAP servers to configure a new set of netgroups to connect WireGuard IP addresses with users, as above.

Once we make the above configuration changes, we can query the members of a netgroup via the getent command. For example, we can query the contents of the wg-users netgroup to see that it contains the following combinations of WireGuard IP addresses and user accounts:

$ getent netgroup wg-users
wg-users              (10.0.0.3,bob,) (10.0.0.1,alice,)

PAM Configuration

Now we can update our PAM configuration to automatically authenticate members of our wg-users netgroup when they attempt to log in via SSH. We’ll add the following PAM rule to the top of the /etc/pam.d/sshd file on Endpoint B:

# /etc/pam.d/sshd
auth    sufficient    pam_succeed_if.so user innetgr wg-users
...

This will authenticate any attempt to log into Endpoint B via SSH if the IP address of the remote host from which the attempt is being made, together with the user account being logged into, matches a host+user combination from our wg-users netgroup. Because we’ve used the sufficient PAM keyword in this rule, if a user doesn’t match a host+user combination, the other authentication methods defined in the /etc/pam.d/sshd config file (such as SSH public-key or password authentication) may allow the user to log in; but if the host+user combination does match, no other authentication methods will be attempted or required.

With the netgroups we’ve defined above, any SSH connection coming from the 10.0.0.1 IP address that tries to log into the alice user account will automatically succeed; and any SSH connection coming from the 10.0.0.3 IP address that tries to log into the bob user account will also automatically succeed. (But note that in our WireGuard configuration for Endpoint B above, we did not define a [Peer] section with an AllowedIPs = 10.0.0.3 setting — so no one will actually be able to log into Endpoint B as bob through WireGuard.)

Firewall Configuration

With the Netgroup and PAM configuration above, we’ve enabled any host using a source IP address of 10.0.0.1 to SSH into Endpoint B as the alice user; and with the WireGuard configuration at the beginning of the article, we’ve guaranteed that the initiator of any connection coming in the WireGuard interface on Endpoint B with a source IP address of 10.0.0.1 legitimately holds the WireGuard private key to Endpoint A.

The one remaining hole we have in this system is the other network interfaces on Endpoint B: If an adversary controls a host on the same LAN subnet as Endpoint B, she could establish an SSH connection to Endpoint B through its LAN network interface using a spoofed source IP address of her own choosing — such as 10.0.0.1. Furthermore, if Endpoint B was also using other, non-WireGuard virtual network interfaces, those virtual interfaces might similarly allow for spoofed or dynamic source IP addresses.

We can block spoofed source IP addresses to other interfaces on Endpoint B in one of two ways:

Anti-Spoofing Kernel Parameters

If we’re only using IPv4, and we’re not doing any fancy routing or other virtual networking on Endpoint B, we can set the net.ipv4.conf.all.rp_filter kernel parameter to 1 to configure the Linux kernel to automatically enforce strict RPF (Reverse Path Forwarding) SAV (Source Address Validation) on the IPv4 packets it receives. The best way to do this is to add the following lines to Endpoint B’s /etc/sysctl.conf file:

# /etc/sysctl.conf
...
net.ipv4.conf.default.rp_filter=1
net.ipv4.conf.all.rp_filter=1

Then run the following command on Endpoint B to re-apply all the kernel parameters set in your sysctl config files:

$ sudo sysctl --system
Note
These kernel parameters are available only for IPv4; for IPv6 you have to use Anti-Spoofing Firewall Rules.

Anti-Spoofing Firewall Rules

Otherwise, we need to manually configure one or more firewall rules to drop any traffic with a source IP address in our WireGuard network’s range (10.0.0.0/24) which Endpoint B did not receive through its WireGuard interface. There are a variety of different ways to do this; but with nftables, the most direct way is to add a rule early in the packet-filtering process that checks the interface and source address of incoming packets, and drops packets if they did not come from WireGuard but are nonetheless using a WireGuard source address:

table inet raw {
    chain prerouting {
        type filter hook prerouting priority raw; policy accept;
        # drop spoofed packets pretending to come through WireGuard tunnel
        iifname != "wg0" ip saddr 10.0.0.0/24 drop
    }
}

The iptables equivalent would be a rule like the following:

iptables -t raw -I PREROUTING ! -i wg0 -s 10.0.0.0/24 -j DROP

Alternatively, we could add an nftables rule that generically checks the interface and source address of incoming packets against Endpoint B’s routing table, and if the interface on which a packet was received is not the interface that reply packets to it will be sent back out, drop the packet:

table inet raw {
    chain prerouting {
        type filter hook prerouting priority raw; policy accept;
        # enforce strict RPF
        fib saddr . mark . iif oif missing drop
    }
}

The iptables equivalent would be a rule like the following:

iptables -t raw -I PREROUTING -m rpfilter --validmark --invert -j DROP

This will work nicely to block most address spoofing attempts in general (although it can cause problems if you’re doing fancy routing on Endpoint B where you actually need to accept legitimate incoming traffic on a network interface that doesn’t match the best route out).

For this article’s scenario, the following nftables configuration is ideal for Endpoint B. It combines the above generic prerouting hook, to protect against source address spoofing; with the recommended nftables base configuration from the How to Use WireGuard With Nftables guide, limiting external access to the host to ICMP, SSH, and WireGuard connections; plus it adds an additional rule to allow full access to all of Endpoint B’s network services through the WireGuard tunnel:

#!/usr/sbin/nft -f
flush ruleset

define pub_iface = "eth0"
define wg_iface = "wg0"
define wg_port = 51822

table inet raw {
    chain prerouting {
        type filter hook prerouting priority raw; policy accept;
        # enforce strict RPF
        fib saddr . mark . iif oif missing drop
    }
}
table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;

        # accept all loopback packets
        iif "lo" accept
        # accept all icmp/icmpv6 packets
        meta l4proto { icmp, ipv6-icmp } accept
        # accept all packets that are part of an already-established connection
        ct state vmap { invalid : drop, established : accept, related : accept }
        # drop new connections over rate limit
        ct state new limit rate over 1/second burst 10 packets drop

        # accept all DHCPv6 packets received at a link-local address
        ip6 daddr fe80::/64 udp dport dhcpv6-client accept
        # accept all SSH packets received on a public interface
        # (remove to block SSH except through WireGuard)
        iifname $pub_iface tcp dport ssh accept
        # accept all WireGuard packets received on a public interface
        iifname $pub_iface udp dport $wg_port accept
        # accept all packets received through the WireGuard tunnel
        iifname $wg_iface accept

        # reject with polite "port unreachable" icmp response
        reject
    }

    chain forward {
        type filter hook forward priority 0; policy drop;
        reject with icmpx type host-unreachable
    }
}

To apply the above nftables configuration, replace Endpoint B’s /etc/nftables.conf (or /etc/sysconfig/nftables.conf) file, and then restart the nftables service (eg sudo systemctl restart nftables).

Test It Out

Start up the WireGuard interfaces on both Endpoint A and Endpoint B (eg by running sudo wg-quick up wg0 on both machines).

Then on Endpoint A, add the following to the top of Alice’s SSH config file (~/.ssh/config):

# ~/.ssh/config
Host wg-endpoint-b
    User alice
    Hostname 10.0.0.2
    PubkeyAuthentication no
    StrictHostKeyChecking no
    UserKnownHostsFile /tmp/known_hosts
    LocalCommand rm /tmp/known_hosts
    PermitLocalCommand yes

This sets up a host alias for wg-endpoint-b. The User alice and Hostname 10.0.0.2 directives configure SSH to use wg-endpoint-b as an alias for alice@10.0.0.2. The PubkeyAuthentication no will prevent SSH from trying SSH public-key authentication. The rest of the directives work together to ignore the SSH host key provided by Endpoint B (as we will instead be relying on Endpoint B’s WireGuard key to authenticate the connection to Endpoint B), and to avoid polluting Alice’s usual known_hosts file with unused entries from Endpoint B.

Specifically, the StrictHostKeyChecking no directive instructs SSH accept any new SSH host keys from Endpoint B, and automatically add them to Alice’s known_hosts file. The UserKnownHostsFile /tmp/known_hosts directive instructs SSH to use /tmp/known_hosts instead of ~/.ssh/known_hosts as Alice’s known_hosts file for Endpoint B. The LocalCommand rm /tmp/known_hosts directive instructs SSH to delete the /tmp/known_hosts file after each successful authentication with Endpoint B. And the PermitLocalCommand yes directive enables the use of LocalCommand.

After updating Alice’s SSH config file, run the following command as Alice on Endpoint A:

$ ssh wg-endpoint-b
Warning: Permanently added '10.0.0.2' (ED25519) to the list of known hosts.
Last login: Sat Feb  3 12:25:32 2024 from 198.51.100.1
alice@bee:~$

This should connect to Endpoint B, and log in Alice automatically, without prompting for a password.

Troubleshooting

If this doesn’t work, check the SSH logs on Endpoint B. If Endpoint B uses systemd, you can use the journalctl -u sshd command to view them. This is what a successful authentication should look like:

$ journalctl -u sshd -e
...
Feb 03 23:15:15 bee sshd[47919]: pam_succeed_if(sshd:auth): requirement "user innetgr wg-users" was met by user "alice"
Feb 03 23:15:15 bee sshd[47893]: Accepted keyboard-interactive/pam for alice from 10.0.0.1 port 58164 ssh2
Feb 03 23:15:15 bee sshd[47893]: pam_unix(sshd:session): session opened for user alice(uid=1001) by alice(uid=0)

You may also find more information in the full auth logs on Endpoint B. If Endpoint B uses systemd, you can use the journalctl --facility auth command to view them. This is what a successful authentication should look like (on a system running SELinux):

$ journalctl --facility auth -e
...
Feb 03 23:15:14 bee audit[47903]: CRYPTO_KEY_USER pid=47903 uid=0 auid=4294967295 ses=4294967295 subj=system_u:system_r:sshd_t:s0-s0:c0.c1023 msg='op=destroy kind=server fp=SHA256:18:92:11:a8:e5:d6:e0:f4:b3:52:37:69:5c:64:28:81:2e:2c:7e:a0:41:1a:43:24:98:0a:6b:6d:d9:22:66:29 direction=? spid=47903 suid=0  exe="/usr/sbin/sshd" hostname=? addr=10.0.0.1 terminal=? res=success'
Feb 03 23:15:15 bee audit[47893]: CRYPTO_SESSION pid=47893 uid=0 auid=4294967295 ses=4294967295 subj=system_u:system_r:sshd_t:s0-s0:c0.c1023 msg='op=start direction=from-server cipher=chacha20-poly1305@openssh.com ksize=512 mac=<implicit> pfs=curve25519-sha256 spid=47903 suid=74 rport=58164 laddr=10.0.0.2 lport=22  exe="/usr/sbin/sshd" hostname=? addr=10.0.0.1 terminal=? res=success'
Feb 03 23:15:15 bee audit[47893]: CRYPTO_SESSION pid=47893 uid=0 auid=4294967295 ses=4294967295 subj=system_u:system_r:sshd_t:s0-s0:c0.c1023 msg='op=start direction=from-client cipher=chacha20-poly1305@openssh.com ksize=512 mac=<implicit> pfs=curve25519-sha256 spid=47903 suid=74 rport=58164 laddr=10.0.0.2 lport=22  exe="/usr/sbin/sshd" hostname=? addr=10.0.0.1 terminal=? res=success'
Feb 03 23:15:15 bee audit[47919]: USER_AUTH pid=47919 uid=0 auid=4294967295 ses=4294967295 subj=system_u:system_r:sshd_t:s0-s0:c0.c1023 msg='op=PAM:authentication grantors=pam_succeed_if acct="alice" exe="/usr/sbin/sshd" hostname=10.0.0.1 addr=10.0.0.1 terminal=ssh res=success'
Feb 03 23:15:15 bee audit[47919]: USER_ACCT pid=47919 uid=0 auid=4294967295 ses=4294967295 subj=system_u:system_r:sshd_t:s0-s0:c0.c1023 msg='op=PAM:accounting grantors=pam_unix,pam_localuser acct="alice" exe="/usr/sbin/sshd" hostname=10.0.0.1 addr=10.0.0.1 terminal=ssh res=success'
Feb 03 23:15:15 bee audit[47893]: CRYPTO_KEY_USER pid=47893 uid=0 auid=4294967295 ses=4294967295 subj=system_u:system_r:sshd_t:s0-s0:c0.c1023 msg='op=destroy kind=session fp=? direction=both spid=47903 suid=74 rport=58164 laddr=10.0.0.2 lport=22  exe="/usr/sbin/sshd" hostname=? addr=10.0.0.1 terminal=? res=success'
Feb 03 23:15:15 bee audit[47893]: CRED_ACQ pid=47893 uid=0 auid=4294967295 ses=4294967295 subj=system_u:system_r:sshd_t:s0-s0:c0.c1023 msg='op=PAM:setcred grantors=pam_env,pam_localuser,pam_unix acct="alice" exe="/usr/sbin/sshd" hostname=10.0.0.1 addr=10.0.0.1 terminal=ssh res=success'
Feb 03 23:15:15 bee audit[47893]: USER_ROLE_CHANGE pid=47893 uid=0 auid=1001 ses=5 subj=system_u:system_r:sshd_t:s0-s0:c0.c1023 msg='pam: default-context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023 selected-context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023 exe="/usr/sbin/sshd" hostname=10.0.0.1 addr=10.0.0.1 terminal=ssh res=success'
Feb 03 23:15:15 bee systemd-logind[741]: New session 5 of user alice.
Feb 03 23:15:15 bee audit[1]: SERVICE_START pid=1 uid=0 auid=4294967295 ses=4294967295 subj=system_u:system_r:init_t:s0 msg='unit=user-runtime-dir@1001 comm="systemd" exe="/usr/lib/systemd/systemd" hostname=? addr=? terminal=? res=success'
Feb 03 23:15:15 bee audit[47924]: USER_ACCT pid=47924 uid=0 auid=4294967295 ses=4294967295 subj=system_u:system_r:init_t:s0 msg='op=PAM:accounting grantors=pam_unix acct="alice" exe="/usr/lib/systemd/systemd" hostname=? addr=? terminal=? res=success'
Feb 03 23:15:15 bee audit[47924]: CRED_ACQ pid=47924 uid=0 auid=4294967295 ses=4294967295 subj=system_u:system_r:init_t:s0 msg='op=PAM:setcred grantors=? acct="alice" exe="/usr/lib/systemd/systemd" hostname=? addr=? terminal=? res=failed'
Feb 03 23:15:15 bee audit[47924]: USER_ROLE_CHANGE pid=47924 uid=0 auid=4294967295 ses=4294967295 subj=system_u:system_r:init_t:s0 msg='pam: default-context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023 selected-context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023 exe="/usr/lib/systemd/systemd" hostname=? addr=? terminal=? res=success'
Feb 03 23:15:15 bee audit[47924]: USER_START pid=47924 uid=0 auid=1001 ses=6 subj=system_u:system_r:init_t:s0 msg='op=PAM:session_open grantors=pam_selinux,pam_selinux,pam_loginuid,pam_keyinit,pam_namespace,pam_systemd_home,pam_umask,pam_keyinit,pam_limits,pam_systemd,pam_unix acct="alice" exe="/usr/lib/systemd/systemd" hostname=? addr=? terminal=? res=success'
Feb 03 23:15:15 bee audit[1]: SERVICE_START pid=1 uid=0 auid=4294967295 ses=4294967295 subj=system_u:system_r:init_t:s0 msg='unit=user@1001 comm="systemd" exe="/usr/lib/systemd/systemd" hostname=? addr=? terminal=? res=success'
Feb 03 23:15:15 bee audit[47893]: USER_START pid=47893 uid=0 auid=1001 ses=5 subj=system_u:system_r:sshd_t:s0-s0:c0.c1023 msg='op=PAM:session_open grantors=pam_selinux,pam_loginuid,pam_selinux,pam_namespace,pam_keyinit,pam_keyinit,pam_limits,pam_systemd,pam_unix,pam_umask,pam_lastlog acct="alice" exe="/usr/sbin/sshd" hostname=10.0.0.1 addr=10.0.0.1 terminal=ssh res=success'
Feb 03 23:15:15 bee audit[47936]: CRYPTO_KEY_USER pid=47936 uid=0 auid=1001 ses=5 subj=system_u:system_r:sshd_t:s0-s0:c0.c1023 msg='op=destroy kind=server fp=SHA256:18:92:11:a8:e5:d6:e0:f4:b3:52:37:69:5c:64:28:81:2e:2c:7e:a0:41:1a:43:24:98:0a:6b:6d:d9:22:66:29 direction=? spid=47936 suid=0  exe="/usr/sbin/sshd" hostname=? addr=10.0.0.1 terminal=? res=success'
Feb 03 23:15:15 bee audit[47936]: CRED_ACQ pid=47936 uid=0 auid=1001 ses=5 subj=system_u:system_r:sshd_t:s0-s0:c0.c1023 msg='op=PAM:setcred grantors=pam_env,pam_localuser,pam_unix acct="alice" exe="/usr/sbin/sshd" hostname=10.0.0.1 addr=10.0.0.1 terminal=ssh res=success'
Feb 03 23:15:15 bee audit[47893]: USER_LOGIN pid=47893 uid=0 auid=1001 ses=5 subj=system_u:system_r:sshd_t:s0-s0:c0.c1023 msg='op=login id=1001 exe="/usr/sbin/sshd" hostname=? addr=10.0.0.1 terminal=/dev/pts/3 res=success'
Feb 03 23:15:15 bee audit[47893]: USER_START pid=47893 uid=0 auid=1001 ses=5 subj=system_u:system_r:sshd_t:s0-s0:c0.c1023 msg='op=login id=1001 exe="/usr/sbin/sshd" hostname=? addr=10.0.0.1 terminal=/dev/pts/3 res=success'
Feb 03 23:15:15 bee audit[47893]: CRYPTO_KEY_USER pid=47893 uid=0 auid=1001 ses=5 subj=system_u:system_r:sshd_t:s0-s0:c0.c1023 msg='op=destroy kind=server fp=SHA256:18:92:11:a8:e5:d6:e0:f4:b3:52:37:69:5c:64:28:81:2e:2c:7e:a0:41:1a:43:24:98:0a:6b:6d:d9:22:66:29 direction=? spid=47937 suid=1001  exe="/usr/sbin/sshd" hostname=? addr=10.0.0.1 terminal=? res=success'
Feb 03 23:15:15 bee audit: BPF prog-id=78 op=LOAD
Feb 03 23:15:15 bee audit: BPF prog-id=79 op=LOAD
Feb 03 23:15:15 bee audit: BPF prog-id=80 op=LOAD
Feb 03 23:15:15 bee audit[1]: SERVICE_START pid=1 uid=0 auid=4294967295 ses=4294967295 subj=system_u:system_r:init_t:s0 msg='unit=systemd-hostnamed comm="systemd" exe="/usr/lib/systemd/systemd" hostname=? addr=? terminal=? res=success'

If nothing shows up in the auth logs on Endpoint B, try using the SSH -vvv option on Endpoint A when connecting. A successful authentication should look like this:

$ ssh -vvv wg-endpoint-b
OpenSSH_9.6p1, OpenSSL 3.1.4 24 Oct 2023
debug1: Reading configuration data /home/ally/.ssh/config
debug1: /home/ally/.ssh/config line 1: Applying options for wg-endpoint-b
debug1: Reading configuration data /etc/ssh/ssh_config
debug1: /etc/ssh/ssh_config line 22: include /etc/ssh/ssh_config.d/*.conf matched no files
debug2: resolve_canonicalize: hostname 10.0.0.2 is address
debug3: channel_clear_timeouts: clearing
debug3: ssh_connect_direct: entering
debug1: Connecting to 10.0.0.2 [10.0.0.2] port 22.
debug3: set_sock_tos: set socket 3 IP_TOS 0x48
debug1: Connection established.
debug1: identity file /home/ally/.ssh/id_rsa type -1
debug1: identity file /home/ally/.ssh/id_rsa-cert type -1
debug1: identity file /home/ally/.ssh/id_ecdsa type -1
debug1: identity file /home/ally/.ssh/id_ecdsa-cert type -1
debug1: identity file /home/ally/.ssh/id_ecdsa_sk type -1
debug1: identity file /home/ally/.ssh/id_ecdsa_sk-cert type -1
debug1: identity file /home/ally/.ssh/id_ed25519 type -1
debug1: identity file /home/ally/.ssh/id_ed25519-cert type -1
debug1: identity file /home/ally/.ssh/id_ed25519_sk type -1
debug1: identity file /home/ally/.ssh/id_ed25519_sk-cert type -1
debug1: identity file /home/ally/.ssh/id_xmss type -1
debug1: identity file /home/ally/.ssh/id_xmss-cert type -1
debug1: identity file /home/ally/.ssh/id_dsa type -1
debug1: identity file /home/ally/.ssh/id_dsa-cert type -1
debug1: Local version string SSH-2.0-OpenSSH_9.6
debug1: Remote protocol version 2.0, remote software version OpenSSH_9.3
debug1: compat_banner: match: OpenSSH_9.3 pat OpenSSH* compat 0x04000000
debug2: fd 3 setting O_NONBLOCK
debug1: Authenticating to 10.0.0.2:22 as 'alice'
debug1: load_hostkeys: fopen /tmp/known_hosts: No such file or directory
debug1: load_hostkeys: fopen /etc/ssh/ssh_known_hosts: No such file or directory
debug1: load_hostkeys: fopen /etc/ssh/ssh_known_hosts2: No such file or directory
debug3: order_hostkeyalgs: no algorithms matched; accept original
debug3: send packet: type 20
debug1: SSH2_MSG_KEXINIT sent
debug3: receive packet: type 20
debug1: SSH2_MSG_KEXINIT received
debug2: local client KEXINIT proposal
debug2: KEX algorithms: sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,diffie-hellman-group14-sha256,ext-info-c,kex-strict-c-v00@openssh.com
debug2: host key algorithms: ssh-ed25519-cert-v01@openssh.com,ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,sk-ssh-ed25519-cert-v01@openssh.com,sk-ecdsa-sha2-nistp256-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,rsa-sha2-512,rsa-sha2-256
debug2: ciphers ctos: chacha20-poly1305@openssh.com,aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com
debug2: ciphers stoc: chacha20-poly1305@openssh.com,aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com
debug2: MACs ctos: umac-64-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha1-etm@openssh.com,umac-64@openssh.com,umac-128@openssh.com,hmac-sha2-256,hmac-sha2-512,hmac-sha1
debug2: MACs stoc: umac-64-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha1-etm@openssh.com,umac-64@openssh.com,umac-128@openssh.com,hmac-sha2-256,hmac-sha2-512,hmac-sha1
debug2: compression ctos: none,zlib@openssh.com,zlib
debug2: compression stoc: none,zlib@openssh.com,zlib
debug2: languages ctos:
debug2: languages stoc:
debug2: first_kex_follows 0
debug2: reserved 0
debug2: peer server KEXINIT proposal
debug2: KEX algorithms: curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,kex-strict-s-v00@openssh.com
debug2: host key algorithms: rsa-sha2-512,rsa-sha2-256,ecdsa-sha2-nistp256,ssh-ed25519
debug2: ciphers ctos: aes256-gcm@openssh.com,chacha20-poly1305@openssh.com,aes256-ctr,aes128-gcm@openssh.com,aes128-ctr
debug2: ciphers stoc: aes256-gcm@openssh.com,chacha20-poly1305@openssh.com,aes256-ctr,aes128-gcm@openssh.com,aes128-ctr
debug2: MACs ctos: hmac-sha2-256-etm@openssh.com,hmac-sha1-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256,hmac-sha1,umac-128@openssh.com,hmac-sha2-512
debug2: MACs stoc: hmac-sha2-256-etm@openssh.com,hmac-sha1-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256,hmac-sha1,umac-128@openssh.com,hmac-sha2-512
debug2: compression ctos: none,zlib@openssh.com
debug2: compression stoc: none,zlib@openssh.com
debug2: languages ctos:
debug2: languages stoc:
debug2: first_kex_follows 0
debug2: reserved 0
debug3: kex_choose_conf: will use strict KEX ordering
debug1: kex: algorithm: curve25519-sha256
debug1: kex: host key algorithm: ssh-ed25519
debug1: kex: server->client cipher: chacha20-poly1305@openssh.com MAC: <implicit> compression: none
debug1: kex: client->server cipher: chacha20-poly1305@openssh.com MAC: <implicit> compression: none
debug3: send packet: type 30
debug1: expecting SSH2_MSG_KEX_ECDH_REPLY
debug3: receive packet: type 31
debug1: SSH2_MSG_KEX_ECDH_REPLY received
debug1: Server host key: ssh-ed25519 SHA256:GJIRqOXW4PSzUjdpXGQogS4sfqBBGkMkmAprbdkiZik
debug1: load_hostkeys: fopen /tmp/known_hosts: No such file or directory
debug1: load_hostkeys: fopen /etc/ssh/ssh_known_hosts: No such file or directory
debug1: load_hostkeys: fopen /etc/ssh/ssh_known_hosts2: No such file or directory
Warning: Permanently added '10.0.0.2' (ED25519) to the list of known hosts.
debug3: send packet: type 21
debug1: ssh_packet_send2_wrapped: resetting send seqnr 3
debug2: ssh_set_newkeys: mode 1
debug1: rekey out after 134217728 blocks
debug1: SSH2_MSG_NEWKEYS sent
debug1: expecting SSH2_MSG_NEWKEYS
debug3: receive packet: type 21
debug1: ssh_packet_read_poll2: resetting read seqnr 3
debug1: SSH2_MSG_NEWKEYS received
debug2: ssh_set_newkeys: mode 0
debug1: rekey in after 134217728 blocks
debug3: send packet: type 5
debug3: receive packet: type 7
debug1: SSH2_MSG_EXT_INFO received
debug3: kex_input_ext_info: extension server-sig-algs
debug1: kex_ext_info_client_parse: server-sig-algs=<ssh-ed25519,sk-ssh-ed25519@openssh.com,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ecdsa-sha2-nistp256@openssh.com,webauthn-sk-ecdsa-sha2-nistp256@openssh.com,ssh-dss,ssh-rsa,rsa-sha2-256,rsa-sha2-512>
debug3: kex_input_ext_info: extension publickey-hostbound@openssh.com
debug1: kex_ext_info_check_ver: publickey-hostbound@openssh.com=(0)
debug3: receive packet: type 6
debug2: service_accept: ssh-userauth
debug1: SSH2_MSG_SERVICE_ACCEPT received
debug3: send packet: type 50
debug3: receive packet: type 51
debug1: Authentications that can continue: publickey,gssapi-keyex,gssapi-with-mic,keyboard-interactive
debug3: start over, passed a different list publickey,gssapi-keyex,gssapi-with-mic,keyboard-interactive
debug3: preferred keyboard-interactive,password
debug3: authmethod_lookup keyboard-interactive
debug3: remaining preferred: password
debug3: authmethod_is_enabled keyboard-interactive
debug1: Next authentication method: keyboard-interactive
debug2: userauth_kbdint
debug3: send packet: type 50
debug2: we sent a keyboard-interactive packet, wait for reply
debug3: receive packet: type 60
debug2: input_userauth_info_req: entering
debug2: input_userauth_info_req: num_prompts 0
debug3: send packet: type 61
debug3: receive packet: type 52
Authenticated to 10.0.0.2 ([10.0.0.2]:22) using "keyboard-interactive".
debug3: expanding LocalCommand: rm /tmp/known_hosts
debug3: expanded LocalCommand: rm /tmp/known_hosts
debug1: channel 0: new session [client-session] (inactive timeout: 0)
debug3: ssh_session2_open: channel_new: 0
debug2: channel 0: send open
debug3: send packet: type 90
debug1: Requesting no-more-sessions@openssh.com
debug3: send packet: type 80
debug3: Executing /bin/sh -c "rm /tmp/known_hosts"
debug1: Entering interactive session.
debug1: pledge: exec
debug3: client_repledge: enter
debug3: receive packet: type 80
debug1: client_input_global_request: rtype hostkeys-00@openssh.com want_reply 0
debug3: receive packet: type 91
debug2: channel_input_open_confirmation: channel 0: callback start
debug2: fd 3 setting TCP_NODELAY
debug3: set_sock_tos: set socket 3 IP_TOS 0x48
debug2: client_session2_setup: id 0
debug2: channel 0: request pty-req confirm 1
debug3: send packet: type 98
debug2: channel 0: request shell confirm 1
debug3: send packet: type 98
debug3: client_repledge: enter
debug1: pledge: fork
debug2: channel_input_open_confirmation: channel 0: callback done
debug2: channel 0: open confirm rwindow 0 rmax 32768
debug3: receive packet: type 99
debug2: channel_input_status_confirm: type 99 id 0
debug2: PTY allocation request accepted on channel 0
debug2: channel 0: rcvd adjust 2097152
debug3: receive packet: type 99
debug2: channel_input_status_confirm: type 99 id 0
debug2: shell request accepted on channel 0
Last login: Sat Feb  3 22:38:59 2024 from 10.0.0.1

Bonus: Telnet

Before SSH, Telnet was the most common way to connect over the network to a computer. On most modern Linux systems you can still install a Telnet server via a package named telnetd or telnet-server, and a Telnet client via a package named telnet or telnet-client.

On most distributions, the Telnet server runs as a systemd socket unit — so after installing the Telnet server, you can start and stop it via the telnet.socket unit. We can verify it’s running by checking the output of the ss -ptunl command (look for TCP port 23):

$ sudo dnf install -qy telnet-server
Installed:
  telnet-server-1:0.17-90.fc39.x86_64
$ sudo systemctl start telnet.socket
$ sudo ss -ptunl
Netid  State   Recv-Q  Send-Q   Local Address:Port      Peer Address:Port  Process
udp    UNCONN  0       0        127.0.0.53%lo:53             0.0.0.0:*      users:(("systemd-resolve",pid=689,fd=17))
udp    UNCONN  0       0              0.0.0.0:51822          0.0.0.0:*
udp    UNCONN  0       0            127.0.0.1:323            0.0.0.0:*      users:(("chronyd",pid=735,fd=5))
tcp    LISTEN  0       128            0.0.0.0:22             0.0.0.0:*      users:(("sshd",pid=967,fd=3))
tcp    LISTEN  0       4096           0.0.0.0:23             0.0.0.0:*      users:(("systemd",pid=1,fd=54))
tcp    LISTEN  0       4096     127.0.0.53%lo:53             0.0.0.0:*      users:(("systemd-resolve",pid=689,fd=18))

On other distros, the Telnet server may run under the generic inetd daemon — so after installing the Telnet server, you can start and stop it by starting and stopping the inetd service (and configure it via the /etc/inetd.conf file):

$ sudo apt-get -qq install telnetd
$ sudo systemctl start inetd
$ sudo ss -ptunl
Netid  State    Recv-Q   Send-Q      Local Address:Port      Peer Address:Port  Process
udp    UNCONN   0        0           127.0.0.53%lo:53             0.0.0.0:*      users:(("systemd-resolve",pid=2906,fd=11))
udp    UNCONN   0        0                 0.0.0.0:51822          0.0.0.0:*
tcp    LISTEN   0        128               0.0.0.0:22             0.0.0.0:*      users:(("sshd",pid=123375,fd=3))
tcp    LISTEN   0        128               0.0.0.0:23             0.0.0.0:*      users:(("inetd",pid=176367,fd=4))
tcp    LISTEN   0        4096        127.0.0.53%lo:53             0.0.0.0:*      users:(("systemd-resolve",pid=2906,fd=12))

To enable WireGuard public-key authentication with Telnet, start with the same WireGuard Configuration, Netgroup Configuration, and Firewall Configuration on Endpoint B as we set up above — but we also need a few more config tweaks, shown below.

PAM Configuration

For Telnet, we’ll configure PAM similarly to what we did for SSH, but we need to configure a different file (not the /etc/pam.d/sshd file). On some distros this may be the /etc/pam.d/remote file; if your distro doesn’t have this file, try the /etc/pam.d/login file instead. Add the following to the top of it:

# /etc/pam.d/remote
auth    sufficient    pam_succeed_if.so user innetgr wg-users
...

This is the same line we added to the /etc/pam.d/sshd file in the SSH PAM Configuration section above, and it will work the same way: It will automatically authenticate members of the wg-users netgroup when they connect to Endpoint B through WireGuard.

Hosts Configuration

The one thing that will work differently with Telnet is that the Telnet server will automatically try to do a reverse hostname lookup on the source IP address used to connect. So when we connect via Telnet through WireGuard from Endpoint A (with an IP address of 10.0.0.1), Endpoint B will try to do a reverse hostname lookup on 10.0.0.1, and use the results of that lookup to determine to what netgroup the connecting user belongs.

To avoid trusting this lookup to DNS, we’ll configure the /etc/hosts file on Endpoint B to provide a private, conceptual hostname for each WireGuard IP address in our WireGuard network. Edit the /etc/hosts file on Endpoint B to add the following to the bottom of the file:

# /etc/hosts
...
10.0.0.1 alice-laptop.private
10.0.0.3 bob-workstation.private

The above config will ensure that when Endpoint B looks up the hostname for 10.0.0.1 (Endpoint A’s WireGuard IP address), it will come up with alice-laptop.private; and if it looks up the hostname for 10.0.0.3 (a WireGuard IP address we might use in the future for Bob’s workstation), it will resolve to bob-workstation.private.

Netgroup Configuration

With those virtual hostnames added to /etc/hosts, go back and edit the /etc/netgroup file on Endpoint B to use them. Add an (alice-laptop.private,alice,) combination to the wg-alice netgroup, and a (bob-workstation.private,bob,) entry to the wg-bob netgroup:

# /etc/netgroup
wg-alice (10.0.0.1,alice,) (alice-laptop.private,alice,)
wg-bob (10.0.0.3,bob,) (bob-workstation.private,bob,)
wg-users wg-alice wg-bob

Now if we run the getent command, we’ll see the new hosts we’ve associated with Alice and Bob — the alice-laptop.private host with the alice user, and the bob-workstation.private host with the bob user:

$ getent netgroup wg-users
wg-users              (10.0.0.3,bob,) (bob-workstation.private,bob,) (10.0.0.1,alice,) (alice-laptop.private,alice,)

When Alice connects via Telnet as the alice user from Endpoint A through her WireGuard connection, the Telnet server on Endpoint B will do a reverse lookup on her WireGuard IP address of 10.0.0.1, and produce the hostname alice-laptop.private. When PAM checks the wg-users netgroup, it will find the (alice-laptop.private,alice,) combination in it, and authenticate this connection.

Test Telnet

To test this out, run the telnet -l alice 10.0.0.2 command from Endpoint A:

$ telnet -l alice 10.0.0.2
Connected to 10.0.0.2

Entering character mode
Escape character is '^]'.

Last login: Sat Feb  3 23:44:19 from 10.0.0.1
alice@bee:~$

We should automatically be logged in as Alice on Endpoint B, without prompting for a password.

If this doesn’t work, check the system logs on Endpoint B for the login identifier. This is what a successful login should look like:

$ journalctl -t login -e
...
Feb 04 00:38:57 bee login[118032]: pam_succeed_if(remote:auth): requirement "user innetgr wg-users" was met by user "alice"
Feb 04 00:38:57 bee login[118032]: pam_unix(remote:session): session opened for user alice(uid=1001) by alice(uid=0)
Feb 04 00:38:57 bee login[118032]: LOGIN ON pts/3 BY alice FROM alice-laptop.private

Bonus: FTP

Just like we set up a Telnet server on Endpoint B as a replacement for SSH terminal access, we can also set up an FTP server as a replacement for SCP, allowing files to be copied back and forth between Endpoint A and B. There are several relatively-modern FTP servers we could install on Endpoint B; we’ll use ProFTPD (as it supports PAM, and includes the correct remote host information for its auth checks).

On most distributions it can be installed via a package named proftpd (or proftpd-core), and will run as its own proftpd daemon:

$ sudo dnf install -qy proftpd
Installed:
  libmemcached-awesome-1.1.4-2.fc39.x86_64 proftpd-1.3.8b-1.fc39.x86_64
$ sudo systemctl start proftpd
$ sudo ss -ptunl
Netid  State   Recv-Q  Send-Q   Local Address:Port      Peer Address:Port  Process
udp    UNCONN  0       0        127.0.0.53%lo:53             0.0.0.0:*      users:(("systemd-resolve",pid=689,fd=17))
udp    UNCONN  0       0              0.0.0.0:51822          0.0.0.0:*
udp    UNCONN  0       0            127.0.0.1:323            0.0.0.0:*      users:(("chronyd",pid=735,fd=5))
tcp    LISTEN  0       9              0.0.0.0:21             0.0.0.0:*      users:(("proftpd",pid=176118,fd=0))
tcp    LISTEN  0       128            0.0.0.0:22             0.0.0.0:*      users:(("sshd",pid=967,fd=3))
tcp    LISTEN  0       4096           0.0.0.0:23             0.0.0.0:*      users:(("systemd",pid=1,fd=54))
tcp    LISTEN  0       4096     127.0.0.53%lo:53             0.0.0.0:*      users:(("systemd-resolve",pid=689,fd=18))

To enable WireGuard public-key authentication with ProFTPD, we’ll use the same WireGuard Configuration, Netgroup Configuration, and Firewall Configuration on Endpoint B as we set up for SSH. We (probably) will only need to modify the PAM Configuration for FTP, as shown below.

FTP Configuration

ProFTPD’s main config file usually can be found at /etc/proftpd.conf or /etc/proftpd/proftpd.conf. Out-of-the-box it should be configured to use PAM authentication; and to not do reverse hostname lookups. If that’s not the case, however, add the following settings to ProFTPD’s configuration, and restart (sudo systemctl restart proftpd):

# /etc/proftpd.conf
...
AuthPAM on
AuthPAMConfig proftpd
AuthOrder mod_auth_pam.c* mod_auth_unix.c
UseReverseDNS off

PAM Configuration

For ProFTPD, we’ll also use a similar PAM Configuration as with SSH, but we again need to configure a different file — in this case, the /etc/pam.d/proftpd file. Add the following to the top of that file:

# /etc/pam.d/proftpd
auth    sufficient    pam_succeed_if.so user innetgr wg-users
...

Test FTP

To test this out, run the ftp alice@10.0.0.2 command from Endpoint A:

$ ftp alice@10.0.0.2
Connected to 10.0.0.2.
220 FTP Server ready.
331 Password required for alice
Password:
230 User alice logged in
Remote system type is UNIX.
Using binary mode to transfer files.
ftp>

We should automatically be logged in as Alice on Endpoint B; if prompted for a password, simply enter a blank password.

To avoid password prompts with the standard command-line FTP client, add the following to Alice’s ~/.netrc file on Endpoint A:

# ~/.netrc
machine 10.0.0.2 login alice password none

We should then be able to connect to Endpoint B without prompts, simply by running ftp 10.0.0.2 from Endpoint A:

$ ftp 10.0.0.2
Connected to 10.0.0.2.
220 FTP Server ready.
331 Password required for alice
230 User alice logged in
Remote system type is UNIX.
Using binary mode to transfer files.
ftp>

If you encounter issues, check the ProFTPD logs on Endpoint B. This is what a successful login should look like:

$ journalctl -u proftpd -e
...
Feb 04 01:50:20 bee proftpd[177215]: session[177215] 203.0.113.2 (10.0.0.1[10.0.0.1]): FTP session opened.
Feb 04 01:50:20 bee proftpd[177215]: pam_succeed_if(proftpd:auth): requirement "user innetgr wg-users" was met by user "alice"
Feb 04 01:50:20 bee proftpd[177215]: pam_unix(proftpd:session): session opened for user alice(uid=1001) by alice(uid=0)
Feb 04 01:50:20 bee proftpd[177215]: session[177215] 203.0.113.2 (10.0.0.1[10.0.0.1]): USER alice: Login successful.

Bonus: RSH

Rlogin (and its companion programs like RSH, RCP, and Rexec) were more “modern” versions of Telnet and FTP that were used in the 80s and 90s, before SSH supplanted all of them. Today, most of these “R commands” aren’t well maintained (as they have fundamental security issues built into their design), and should be avoided in preference to SSH (or Telnet & FTP through a secure connection, like WireGuard).

But you can still install the server and client programs on many current Linux distributions — and use WireGuard to run them securely. On most distributions you can install a dedicated rsh-server package that will include the Rexec, Rlogin, and RSH servers; on other distros they may be included in a larger inetutils package that contains most of the GNU Inetutils. (We’ll ignore the Rexec server, as it supports only password authentication — plus it doesn’t do anything that RSH itself can’t.)

On most distributions, these servers run as a systemd socket unit — so after installing them, we can start and stop the Rlogin server via the rlogin.socket unit, and the RSH server via the rsh.socket unit. We can verify they’re running by checking the output of the ss -ptunl command (look for TCP ports 513 and 514):

$ sudo dnf install -qy rsh-server
Installed:
  rsh-server-0.17-106.fc39.x86_64
$ sudo systemctl start rlogin.socket
$ sudo systemctl start rsh.socket
$ sudo ss -ptunl
Netid  State   Recv-Q  Send-Q   Local Address:Port      Peer Address:Port  Process
udp    UNCONN  0       0        127.0.0.53%lo:53             0.0.0.0:*      users:(("systemd-resolve",pid=689,fd=17))
udp    UNCONN  0       0              0.0.0.0:51822          0.0.0.0:*
udp    UNCONN  0       0            127.0.0.1:323            0.0.0.0:*      users:(("chronyd",pid=735,fd=5))
tcp    LISTEN  0       128            0.0.0.0:22             0.0.0.0:*      users:(("sshd",pid=967,fd=3))
tcp    LISTEN  0       4096           0.0.0.0:513            0.0.0.0:*      users:(("systemd",pid=1,fd=54))
tcp    LISTEN  0       4096           0.0.0.0:514            0.0.0.0:*      users:(("systemd",pid=1,fd=55))
tcp    LISTEN  0       4096     127.0.0.53%lo:53             0.0.0.0:*      users:(("systemd-resolve",pid=689,fd=18))

On other distros, these servers may run under the generic inetd daemon — so after installing the servers, we can start and stop them by starting and stopping the inetd service. The Rexec server listens on TCP port 512, the Rlogin server listens on TCP port 513, and the RSH server listens on TCP port 514:

$ sudo apt-get -qq install rsh-server
$ sudo systemctl start inetd
$ sudo ss -ptunl
Netid  State    Recv-Q   Send-Q      Local Address:Port      Peer Address:Port  Process
udp    UNCONN   0        0           127.0.0.53%lo:53             0.0.0.0:*      users:(("systemd-resolve",pid=2906,fd=11))
udp    UNCONN   0        0                 0.0.0.0:51822          0.0.0.0:*
tcp    LISTEN   0        128               0.0.0.0:22             0.0.0.0:*      users:(("sshd",pid=123375,fd=3))
tcp    LISTEN   0        128               0.0.0.0:514            0.0.0.0:*      users:(("inetd",pid=176367,fd=8))
tcp    LISTEN   0        128               0.0.0.0:513            0.0.0.0:*      users:(("inetd",pid=176367,fd=9))
tcp    LISTEN   0        128               0.0.0.0:512            0.0.0.0:*      users:(("inetd",pid=176367,fd=10))
tcp    LISTEN   0        4096        127.0.0.53%lo:53             0.0.0.0:*      users:(("systemd-resolve",pid=2906,fd=12))

To enable WireGuard public-key authentication with Rlogin and RSH, we’ll use the same WireGuard Configuration and Firewall Configuration on Endpoint B as we set up for SSH — but instead of using netgroups, we’ll use Rhosts Configuration to associate WireGuard IP addresses with users.

Rhosts Configuration

Traditional Rlogin authentication relies on the ~/.rhosts file in each individual user’s home folder (conceptually similar to the ~/.ssh/authorized_keys file used by SSH public-key authentication). To allow Alice to log in automatically with Rlogin over WireGuard from Endpoint A (with a WireGuard IP address of 10.0.0.1), add the following entry to Alice’s ~/.rhosts file on Endpoint B:

# /home/alice/.rhosts
10.0.0.1 +

The first part of the entry (10.0.0.1) is the remote host; the second part of the entry is the user account on that remote host (`). The ` symbol is a wildcard — we’re using a wildcard for the user account, since it has no effect on security (anyone using rlogin can claim to be using any remote user account). What matters in our scenario is the host: the IP address of 10.0.0.1 is not spoofable, due to the WireGuard Configuration and Firewall Configuration we’ve set up above.

Note

The Rlogin server will also attempt reverse hostname lookups, just like Telnet. However, unlike Telnet, the Rlogin authentication mechanism will check for matches with both the result of the hostname lookup, and the original source IP address.

Therefore, you may wish to set up the same Hosts Configuration with Rlogin as we used for Telnet (which would, for example, produce alice-laptop.private when looking up the hostname for 10.0.0.1). If so, you could then use resolved hostnames in some of your ~/.rhosts files, instead of the original WireGuard IP addresses; for example, like the following:

# /home/alice/.rhosts
alice-laptop.private +

(Also, even if you don’t use resolved hostnames in your ~/.rhosts files, you may wish to set up this Hosts Configuration to see the resolved hostnames in your log files.)

Tip

If setting up ~/.rhosts files for user accounts other than your own, make sure you set the owner of each file to the matching user (and that each file can be written only by owner):

$ sudo chown alice /home/alice/.rhosts && sudo chmod 600 /home/alice/.rhosts

And if using SELinux, make sure the file has the right SELinux context:

$ sudo restorecon /home/alice/.rhosts
Note

Instead of setting up and using traditional Rlogin authentication as shown above, we could alternatively have set up the same Netgroup Configuration and Hosts Configuration as we used for Telnet; and then edit the PAM configuration for Rlogin (/etc/pam.d/rlogin) and RSH (/etc/pam.d/rsh) to add the same auth sufficient pam_succeed_if.so user innetgr wg-users line as we used in the other PAM Configuration files. If we did that, instead of requiring per-user ~/.rhosts files, we could authenticate via a central wg-users netgroup like we did for SSH, Telnet, and FTP.

Test Rlogin

The Rlogin, RSH, and other “R command” client programs are part of a few different packages on different distributions — try either the rsh-client, rsh, or inetutils packages. Install the appropriate package with the client programs on both Endpoint A and Endpoint B.

Then run the rlogin -l alice 10.0.0.2 command from Endpoint A:

$ rlogin -l alice 10.0.0.2
Last login: Sun Feb  4 05:02:32 from 10.0.0.1
alice@bee:~$

We should automatically be logged into a terminal session on Endpoint B as Alice (similar to Telnet), without prompting for a password.

If this doesn’t work, check the system logs on Endpoint B for the rlogind identifier. This is what a successful login should look like:

$ journalctl -t rlogind -e
Feb 04 05:07:24 bee rlogind[340100]: pam_rhosts(rlogin:auth): allowed access to ally@10.0.0.1 as alice
Note

In the above Endpoint B log entry, the first user listed (ally@10.0.0.1) is the remote user (Alice on Endpoint A); the second user (alice) is the local user (Alice on Endpoint B).

Test RSH

While the rlogin client command provides an interactive terminal (like the telnet client, or the ssh client with no remote command), the rsh client command simply runs a remote command and prints the results.

Try running the following command from Endpoint A (which runs the hostname command remotely on Endpoint B):

$ rsh -l alice 10.0.0.2 hostname
bee

If this doesn’t work, check the system logs on Endpoint B for the rshd identifier. This is what a successful RSH command should look like:

$ journalctl -t rshd -e
Feb 04 06:09:34 bee rshd[392565]: pam_rhosts(rsh:auth): allowed access to ally@10.0.0.1 as alice
Feb 04 06:09:35 bee rshd[392565]: pam_unix(rsh:session): session opened for user alice(uid=1001) by alice(uid=0)
Feb 04 06:09:35 bee rshd[392565]: pam_unix(rsh:session): session closed for user alice
Note

Running rsh with no remote command will actually run the rlogin client instead:

$ rsh -l alice 10.0.0.2
Last login: Sun Feb  4 05:07:24 from 10.0.0.1
alice@bee:~$

Test RCP

The rcp client command also uses the RSH service. We can test it out by creating a foo.txt file remotely on Endpoint B, and then copying that file over locally to Endpoint A:

$ rsh -l alice 10.0.0.2 'echo foo > foo.txt'
$ rsh -l alice 10.0.0.2 cat foo.txt
foo
$ rcp alice@10.0.0.2:foo.txt .
$ cat foo.txt
foo
Tip

If you see the following error after running the rcp command, it means you need to install the rsh-client package on the other host (Endpoint B):

bash: line 1: rcp: command not found

Test Rsync

Rsync is maintained separately from the other “R commands” (and usually it comes in its own rsync package); and modern versions of Rsync use SSH as its default transport — but originally, Rsync used RSH. We can still direct Rsync to use RSH, however, via the -e flag (eg rsync -e rsh).

We can test this out by creating a bar directory containing some files remotely on Endpoint B, and then syncing that directory locally to Endpoint A:

$ rsh -l alice 10.0.0.2 mkdir bar
$ rsh -l alice 10.0.0.2 'echo baz > bar/baz.txt'
$ rsh -l alice 10.0.0.2 'echo qux > bar/qux.txt'
$ rsync -e rsh -av alice@10.0.0.2:bar/ bar/
receiving incremental file list
created directory bar
./
baz.txt
qux.txt

sent 65 bytes  received 202 bytes  178.00 bytes/sec
total size is 8  speedup is 0.03
$ ls bar
baz.txt  qux.txt