From 69c40f7ee1f55ed01c3869318cf61e7a46ee1701 Mon Sep 17 00:00:00 2001 From: Michael Buesch Date: Sun, 7 Jul 2024 12:19:00 +0200 Subject: [PATCH] Split the daemon into network and firewall part --- Cargo.lock | 52 +++ Cargo.toml | 8 + README.md | 50 ++- build.sh | 2 + create-user.sh | 76 ++++ install-server.sh | 53 ++- letmein-conf/src/lib.rs | 2 + letmein-fwproto/Cargo.toml | 21 ++ letmein-fwproto/README.md | 15 + letmein-fwproto/src/lib.rs | 337 ++++++++++++++++++ letmein-seccomp/Cargo.toml | 26 ++ letmein-seccomp/README.md | 15 + letmein-seccomp/build.rs | 16 + letmein-seccomp/src/lib.rs | 163 +++++++++ letmein-systemd/Cargo.toml | 1 + letmein-systemd/src/lib.rs | 47 ++- letmeind/Cargo.toml | 5 +- letmeind/letmeind.service | 10 +- letmeind/letmeind.socket | 4 +- letmeind/src/firewall_client.rs | 54 +++ letmeind/src/main.rs | 178 +++++---- letmeind/src/processor.rs | 32 +- letmeinfwd/Cargo.toml | 27 ++ letmeinfwd/README.md | 15 + letmeinfwd/letmeinfwd.service | 19 + letmeinfwd/letmeinfwd.socket | 15 + {letmeind => letmeinfwd}/src/firewall.rs | 0 .../src/firewall/nftables.rs | 0 letmeinfwd/src/main.rs | 260 ++++++++++++++ letmeinfwd/src/server.rs | 145 ++++++++ 30 files changed, 1536 insertions(+), 112 deletions(-) create mode 100755 create-user.sh create mode 100644 letmein-fwproto/Cargo.toml create mode 100644 letmein-fwproto/README.md create mode 100644 letmein-fwproto/src/lib.rs create mode 100644 letmein-seccomp/Cargo.toml create mode 100644 letmein-seccomp/README.md create mode 100644 letmein-seccomp/build.rs create mode 100644 letmein-seccomp/src/lib.rs create mode 100644 letmeind/src/firewall_client.rs create mode 100644 letmeinfwd/Cargo.toml create mode 100644 letmeinfwd/README.md create mode 100644 letmeinfwd/letmeinfwd.service create mode 100644 letmeinfwd/letmeinfwd.socket rename {letmeind => letmeinfwd}/src/firewall.rs (100%) rename {letmeind => letmeinfwd}/src/firewall/nftables.rs (100%) create mode 100644 letmeinfwd/src/main.rs create mode 100644 letmeinfwd/src/server.rs diff --git a/Cargo.lock b/Cargo.lock index 6d53147..6a30ac6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -470,6 +470,14 @@ dependencies = [ "letmein-proto", ] +[[package]] +name = "letmein-fwproto" +version = "2.0.0" +dependencies = [ + "anyhow", + "tokio", +] + [[package]] name = "letmein-proto" version = "2.0.0" @@ -482,6 +490,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "letmein-seccomp" +version = "2.0.0" +dependencies = [ + "anyhow", + "autocfg", + "libc", + "seccompiler", +] + [[package]] name = "letmein-systemd" version = "2.0.0" @@ -498,10 +516,26 @@ dependencies = [ "anyhow", "clap", "letmein-conf", + "letmein-fwproto", "letmein-proto", + "letmein-seccomp", "letmein-systemd", + "tokio", +] + +[[package]] +name = "letmeinfwd" +version = "2.0.0" +dependencies = [ + "anyhow", + "clap", + "letmein-conf", + "letmein-fwproto", + "letmein-systemd", + "libc", "nftables", "tokio", + "user_lookup", ] [[package]] @@ -747,6 +781,15 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4646d6f919800cd25c50edb49438a1381e2cd4833c027e75e8897981c50b8b5e" +[[package]] +name = "seccompiler" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "345a3e4dddf721a478089d4697b83c6c0a8f5bf16086f6c13397e4534eb6e2e5" +dependencies = [ + "libc", +] + [[package]] name = "serde" version = "1.0.204" @@ -1006,6 +1049,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "user_lookup" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce218b4b10d1acf525e5798d1014bc9922f0d11c0267ddc4be4c14775124e209" +dependencies = [ + "tokio", +] + [[package]] name = "utf8parse" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 55f739b..744a266 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,9 +4,12 @@ members = [ "letmein", "letmein-conf", + "letmein-fwproto", "letmein-proto", + "letmein-seccomp", "letmein-systemd", "letmeind", + "letmeinfwd", ] resolver = "2" @@ -24,6 +27,7 @@ keywords = [ "port-knocking", "port", "knock", "firewall", "nftables" ] [workspace.dependencies] anyhow = "1" +autocfg = "1" clap = "4" getrandom = "0.2" hickory-proto = "0.24" @@ -32,12 +36,16 @@ hmac = "0.12" libc = "0.2" nftables = "0.4" sd-notify = "0.4" +seccompiler = "0.4" sha3 = "0.10" subtle = "2" tokio = "1" +user_lookup = { version = "0.3", default-features = false } letmein-conf = { version = "2", path = "./letmein-conf" } +letmein-fwproto = { version = "2", path = "./letmein-fwproto" } letmein-proto = { version = "2", path = "./letmein-proto" } +letmein-seccomp = { version = "2", path = "./letmein-seccomp" } letmein-systemd = { version = "2", path = "./letmein-systemd" } [profile.release] diff --git a/README.md b/README.md index 11d1a30..8ccf801 100644 --- a/README.md +++ b/README.md @@ -138,43 +138,45 @@ After installing all build prerequisites, run the build script: ## Installing letmein -### Install server +### Install client -After building, run the `install-server.sh` to install the letmeind server to `/opt/letmein/`: +Then run the `install-client.sh` to install the letmein client to `/opt/letmein/`: ```sh -./install-server.sh +./install-client.sh ``` -Installing the server will also install the service and socket into systemd and start the letmeind server. +The client is used to send a knock packet to the server. -The server is used to receive knock packets from the client. -Upon successful knock authentication, the server will open the knocked port in its `nftables` firewall. +### Install server -### Install client +#### Prepare user and group for the server -Then run the `install-client.sh` to install the letmein client to `/opt/letmein/`: +The public network facing part of the letmein server runs with reduced privileges to reduce the attack surface. + +For this to work, the system user `letmeind` and a system group `letmeind` have to be present in `/etc/passwd` and `/etc/group` + +You can use the following helper script to create the user and group in your system: ```sh -./install-client.sh +./create-user.sh ``` -The client is used to send a knock packet to the server. +#### Install the server and systemd units -## Security notice: User identifiers and resource identifiers +After building and creating the `letmeind` system user, run the `install-server.sh` to install the letmeind server to `/opt/letmein/`: -Please be aware that the user identifiers and resource identifiers from the configuration files are transmitted over the network without encryption in clear text. +```sh +./install-server.sh +``` -Make sure the user identifiers and resource identifiers do **not** include any private information. +Installing the server will also install the service and socket into systemd and start the letmeind server. -These identifiers are merely meant to be an abstract identification for managing different `letmein` keys, installations and setups. +The server is used to receive knock packets from the client. +Upon successful knock authentication, the server will open the knocked port in its `nftables` firewall. ## Platform support -### Server - -The server application `letmeind` is Linux-only, because it only supports `nftables` as firewall backend. - ### Client The client application `letmein` is portable and should run on all major platforms. @@ -185,6 +187,18 @@ Tested platforms are: - Windows - MacOS (build tested only) +### Server + +The server application `letmeind` is Linux-only, because it only supports `nftables` as firewall backend. + +## Security notice: User identifiers and resource identifiers + +Please be aware that the user identifiers and resource identifiers from the configuration files are transmitted over the network without encryption in clear text. + +Make sure the user identifiers and resource identifiers do **not** include any private information. + +These identifiers are merely meant to be an abstract identification for managing different `letmein` keys, installations and setups. + ## Internals and design goals The main design goals of letmein are: diff --git a/build.sh b/build.sh index 0f60318..45f1f26 100755 --- a/build.sh +++ b/build.sh @@ -53,8 +53,10 @@ cargo auditable build --release || die "Cargo build (release) failed." cargo audit bin --deny warnings \ target/release/letmein \ target/release/letmeind \ + target/release/letmeinfwd \ || die "Cargo audit failed." check_dynlibs target/release/letmein check_dynlibs target/release/letmeind +check_dynlibs target/release/letmeinfwd # vim: ts=4 sw=4 expandtab diff --git a/create-user.sh b/create-user.sh new file mode 100755 index 0000000..b02ccf4 --- /dev/null +++ b/create-user.sh @@ -0,0 +1,76 @@ +#!/bin/sh +# -*- coding: utf-8 -*- + +info() +{ + echo "--- $*" +} + +error() +{ + echo "=== ERROR: $*" >&2 +} + +warning() +{ + echo "=== WARNING: $*" >&2 +} + +die() +{ + error "$*" + exit 1 +} + +entry_checks() +{ + [ "$(id -u)" = "0" ] || die "Must be root to create users." +} + +sys_groupadd() +{ + local args="--system" + info "groupadd $args $*" + groupadd $args "$@" || die "Failed groupadd" +} + +sys_useradd() +{ + local args="--system -s /usr/sbin/nologin -d /nonexistent -M -N" + info "useradd $args $*" + useradd $args "$@" || die "Failed useradd" +} + +do_usermod() +{ + info "usermod $*" + usermod "$@" || die "Failed usermod" +} + +stop_daemons() +{ + systemctl stop letmeind.socket >/dev/null 2>&1 + systemctl stop letmeind.service >/dev/null 2>&1 + systemctl stop letmeinfwd.socket >/dev/null 2>&1 + systemctl stop letmeinfwd.service >/dev/null 2>&1 +} + +remove_users() +{ + # Delete all existing users and groups, if any. + userdel letmeind >/dev/null 2>&1 + groupdel letmeind >/dev/null 2>&1 +} + +add_users() +{ + sys_groupadd letmeind + sys_useradd -g letmeind letmeind +} + +entry_checks +stop_daemons +remove_users +add_users + +# vim: ts=4 sw=4 expandtab diff --git a/install-server.sh b/install-server.sh index 74d5d8b..2903521 100755 --- a/install-server.sh +++ b/install-server.sh @@ -36,6 +36,18 @@ do_systemctl() systemctl "$@" || die "Failed to systemctl $*" } +do_chown() +{ + info "chown $*" + chown "$@" || die "Failed to chown $*" +} + +do_chmod() +{ + info "chmod $*" + chmod "$@" || die "Failed to chmod $*" +} + try_systemctl() { info "systemctl $*" @@ -45,19 +57,32 @@ try_systemctl() entry_checks() { [ -d "$target" ] || die "letmein is not built! Run ./build.sh" + [ "$(id -u)" = "0" ] || die "Must be root to install letmein." + + if ! grep -qe letmeind /etc/passwd; then + die "The system user 'letmeind' does not exist in /etc/passwd. Please run ./create-user.sh" + fi + if ! grep -qe letmeind /etc/group; then + die "The system group 'letmeind' does not exist in /etc/group. Please run ./create-user.sh" + fi } stop_services() { try_systemctl stop letmeind.socket try_systemctl stop letmeind.service + try_systemctl stop letmeinfwd.socket + try_systemctl stop letmeinfwd.service try_systemctl disable letmeind.service try_systemctl disable letmeind.socket + try_systemctl disable letmeinfwd.service + try_systemctl disable letmeinfwd.socket } start_services() { + do_systemctl start letmeinfwd.socket do_systemctl start letmeind.socket } @@ -74,14 +99,37 @@ install_dirs() install_conf() { - if ! [ -e /opt/letmein/etc/letmeind.conf ]; then + if [ -e /opt/letmein/etc/letmeind.conf ]; then + do_chown root:letmeind /opt/letmein/etc/letmeind.conf + do_chmod 0640 /opt/letmein/etc/letmeind.conf + else do_install \ - -o root -g root -m 0640 \ + -o root -g letmeind -m 0640 \ "$basedir/letmeind/letmeind.conf" \ /opt/letmein/etc/letmeind.conf fi } +install_letmeinfwd() +{ + do_install \ + -o root -g root -m 0755 \ + "$target/letmeinfwd" \ + /opt/letmein/bin/ + + do_install \ + -o root -g root -m 0644 \ + "$basedir/letmeinfwd/letmeinfwd.service" \ + /etc/systemd/system/ + + do_install \ + -o root -g root -m 0644 \ + "$basedir/letmeinfwd/letmeinfwd.socket" \ + /etc/systemd/system/ + + do_systemctl enable letmeind.socket +} + install_letmeind() { do_install \ @@ -123,6 +171,7 @@ entry_checks stop_services install_dirs install_conf +install_letmeinfwd install_letmeind start_services diff --git a/letmein-conf/src/lib.rs b/letmein-conf/src/lib.rs index f8f5e64..3ef48f1 100644 --- a/letmein-conf/src/lib.rs +++ b/letmein-conf/src/lib.rs @@ -14,6 +14,8 @@ #![forbid(unsafe_code)] +//TODO move the seccomp option into conf + mod ini; use crate::ini::Ini; diff --git a/letmein-fwproto/Cargo.toml b/letmein-fwproto/Cargo.toml new file mode 100644 index 0000000..9e7ac41 --- /dev/null +++ b/letmein-fwproto/Cargo.toml @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- + +[package] +name = "letmein-fwproto" +description = "Authenticated port knocking - Firewall backend communication protocol" +version = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +license = { workspace = true } +authors = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } +readme = "README.md" +categories = { workspace = true } +keywords = { workspace = true } + +[dependencies] +anyhow = { workspace = true } +tokio = { workspace = true, features = [ "net" ] } + +# vim: ts=4 sw=4 expandtab diff --git a/letmein-fwproto/README.md b/letmein-fwproto/README.md new file mode 100644 index 0000000..d4c796c --- /dev/null +++ b/letmein-fwproto/README.md @@ -0,0 +1,15 @@ +# letmein - Authenticated port knocking + +[Homepage](https://bues.ch/h/letmein) + +[Git repository](https://bues.ch/cgit/letmein.git) + +[Github repository](https://github.com/mbuesch/letmein) + +This is a library crate for the `letmein` application. + +# License + +Copyright (c) 2024 Michael Büsch + +Licensed under the Apache License version 2.0 or the MIT license, at your option. diff --git a/letmein-fwproto/src/lib.rs b/letmein-fwproto/src/lib.rs new file mode 100644 index 0000000..06435d6 --- /dev/null +++ b/letmein-fwproto/src/lib.rs @@ -0,0 +1,337 @@ +// -*- coding: utf-8 -*- +// +// Copyright (C) 2024 Michael Büsch +// +// Licensed under the Apache License version 2.0 +// or the MIT license, at your option. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +#![forbid(unsafe_code)] + +#[cfg(not(any(target_os = "linux", target_os = "android")))] +std::compile_error!("letmeind server and letmein-fwproto do not support non-Linux platforms."); + +use anyhow::{self as ah, format_err as err, Context as _}; +use std::net::{IpAddr, Ipv4Addr}; +use tokio::{io::ErrorKind, net::UnixStream}; + +/// Firewall daemon Unix socket file name. +pub const SOCK_FILE: &str = "letmeinfwd.sock"; + +/// The operation to perform on the firewall. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[repr(u16)] +pub enum FirewallOperation { + /// Open an IPv6 port. + OpenV6, + /// Open an IPv4 port. + OpenV4, + /// Acknowledge message. + Ack, + /// Not-Acknowledge message. + Nack, +} + +impl TryFrom for FirewallOperation { + type Error = ah::Error; + + fn try_from(value: u16) -> Result { + const OPERATION_OPENV6: u16 = FirewallOperation::OpenV6 as u16; + const OPERATION_OPENV4: u16 = FirewallOperation::OpenV4 as u16; + const OPERATION_ACK: u16 = FirewallOperation::Ack as u16; + const OPERATION_NACK: u16 = FirewallOperation::Nack as u16; + match value { + OPERATION_OPENV6 => Ok(Self::OpenV6), + OPERATION_OPENV4 => Ok(Self::OpenV4), + OPERATION_ACK => Ok(Self::Ack), + OPERATION_NACK => Ok(Self::Nack), + _ => Err(err!("Invalid FirewallMessage/Operation value")), + } + } +} + +/// Size of the `addr` field in the message. +const ADDR_SIZE: usize = 16; + +/// Size of the firewall control message. +const FWMSG_SIZE: usize = 2 + 2 + ADDR_SIZE; + +/// Byte offset of the `operation` field in the firewall control message. +const FWMSG_OFFS_OPERATION: usize = 0; + +/// Byte offset of the `port` field in the firewall control message. +const FWMSG_OFFS_PORT: usize = 2; + +/// Byte offset of the `addr` field in the firewall control message. +const FWMSG_OFFS_ADDR: usize = 4; + +/// A message to control the firewall. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct FirewallMessage { + operation: FirewallOperation, + port: u16, + addr: [u8; ADDR_SIZE], +} + +/// Convert an `IpAddr` to the `addr` field of a firewall control message. +fn addr_to_octets(addr: IpAddr) -> [u8; ADDR_SIZE] { + match addr { + IpAddr::V4(addr) => { + let o = addr.octets(); + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, o[0], o[1], o[2], o[3]] + } + IpAddr::V6(addr) => addr.octets(), + } +} + +/// Convert a firewall control message `addr` field to an `IpAddr`. +fn octets_to_addr(operation: FirewallOperation, addr: &[u8; ADDR_SIZE]) -> Option { + match operation { + FirewallOperation::OpenV4 => { + Some(Ipv4Addr::new(addr[12], addr[13], addr[14], addr[15]).into()) + } + FirewallOperation::OpenV6 => Some((*addr).into()), + FirewallOperation::Ack | FirewallOperation::Nack => None, + } +} + +impl FirewallMessage { + /// Construct a new message that requests installing a firewall-port-open rule. + pub fn new_open(addr: IpAddr, port: u16) -> Self { + let operation = match addr { + IpAddr::V4(_) => FirewallOperation::OpenV4, + IpAddr::V6(_) => FirewallOperation::OpenV6, + }; + let addr = addr_to_octets(addr); + Self { + operation, + port, + addr, + } + } + + /// Construct a new acknowledge message. + pub fn new_ack() -> Self { + Self { + operation: FirewallOperation::Ack, + port: 0, + addr: [0; ADDR_SIZE], + } + } + + /// Construct a new not-acknowledge message. + pub fn new_nack() -> Self { + Self { + operation: FirewallOperation::Nack, + port: 0, + addr: [0; ADDR_SIZE], + } + } + + /// Get the operation type from this message. + pub fn operation(&self) -> FirewallOperation { + self.operation + } + + /// Get the port number from this message. + pub fn port(&self) -> Option { + match self.operation { + FirewallOperation::OpenV4 | FirewallOperation::OpenV6 => Some(self.port), + FirewallOperation::Ack | FirewallOperation::Nack => None, + } + } + + /// Get the `IpAddr` from this message. + pub fn addr(&self) -> Option { + octets_to_addr(self.operation, &self.addr) + } + + /// Serialize this message into a byte stream. + pub fn msg_serialize(&self) -> ah::Result<[u8; FWMSG_SIZE]> { + // The serialization is simple enough to do manually. + // Therefore, we don't use the `serde` crate here. + + #[inline] + fn serialize_u16(buf: &mut [u8], value: u16) { + buf[0..2].copy_from_slice(&value.to_be_bytes()); + } + + let mut buf = [0; FWMSG_SIZE]; + serialize_u16(&mut buf[FWMSG_OFFS_OPERATION..], self.operation as u16); + serialize_u16(&mut buf[FWMSG_OFFS_PORT..], self.port); + buf[FWMSG_OFFS_ADDR..FWMSG_OFFS_ADDR + ADDR_SIZE].copy_from_slice(&self.addr); + + Ok(buf) + } + + /// Try to deserialize a byte stream into a message. + pub fn try_msg_deserialize(buf: &[u8]) -> ah::Result { + if buf.len() != FWMSG_SIZE { + return Err(err!("Deserialize: Raw message size mismatch.")); + } + + // The deserialization is simple enough to do manually. + // Therefore, we don't use the `serde` crate here. + + #[inline] + fn deserialize_u16(buf: &[u8]) -> ah::Result { + Ok(u16::from_be_bytes(buf[0..2].try_into()?)) + } + + let operation = deserialize_u16(&buf[FWMSG_OFFS_OPERATION..])?; + let port = deserialize_u16(&buf[FWMSG_OFFS_PORT..])?; + let addr = &buf[FWMSG_OFFS_ADDR..FWMSG_OFFS_ADDR + ADDR_SIZE]; + + Ok(Self { + operation: operation.try_into()?, + port, + addr: addr.try_into()?, + }) + } + + /// Send this message over a [UnixStream]. + pub async fn send(&self, stream: &mut UnixStream) -> ah::Result<()> { + let txbuf = self.msg_serialize()?; + let mut txcount = 0; + loop { + stream.writable().await.context("Socket polling (tx)")?; + match stream.try_write(&txbuf[txcount..]) { + Ok(n) => { + txcount += n; + assert!(txcount <= txbuf.len()); + if txcount == txbuf.len() { + return Ok(()); + } + } + Err(e) if e.kind() == ErrorKind::WouldBlock => (), + Err(e) => { + return Err(err!("Socket write: {e}")); + } + } + } + } + + /// Try to receive a message from a [UnixStream]. + pub async fn recv(stream: &mut UnixStream) -> ah::Result> { + let mut rxbuf = [0; FWMSG_SIZE]; + let mut rxcount = 0; + loop { + stream.readable().await.context("Socket polling (rx)")?; + match stream.try_read(&mut rxbuf[rxcount..]) { + Ok(n) => { + if n == 0 { + return Ok(None); + } + rxcount += n; + assert!(rxcount <= FWMSG_SIZE); + if rxcount == FWMSG_SIZE { + return Ok(Some(Self::try_msg_deserialize(&rxbuf)?)); + } + } + Err(e) if e.kind() == ErrorKind::WouldBlock => (), + Err(e) => { + return Err(err!("Socket read: {e}")); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn check_ser_de(msg: &FirewallMessage) { + // Serialize a message and then deserialize the byte stream + // and check if the resulting message is the same. + let bytes = msg.msg_serialize().unwrap(); + let msg_de = FirewallMessage::try_msg_deserialize(&bytes).unwrap(); + assert_eq!(*msg, msg_de); + } + + #[test] + fn test_msg_open_v6() { + let msg = FirewallMessage::new_open("::1".parse().unwrap(), 0x9876); + assert_eq!(msg.operation(), FirewallOperation::OpenV6); + assert_eq!(msg.port(), Some(0x9876)); + assert_eq!(msg.addr(), Some("::1".parse().unwrap())); + check_ser_de(&msg); + + let msg = FirewallMessage::new_open( + "0102:0304:0506:0708:090A:0B0C:0D0E:0F10".parse().unwrap(), + 0x9876, + ); + let bytes = msg.msg_serialize().unwrap(); + assert_eq!( + bytes, + [ + 0x00, 0x00, // operation + 0x98, 0x76, // port + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, // addr + 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, // addr + ] + ); + } + + #[test] + fn test_msg_open_v4() { + let msg = FirewallMessage::new_open("1.2.3.4".parse().unwrap(), 0x9876); + assert_eq!(msg.operation(), FirewallOperation::OpenV4); + assert_eq!(msg.port(), Some(0x9876)); + assert_eq!(msg.addr(), Some("1.2.3.4".parse().unwrap())); + check_ser_de(&msg); + + let bytes = msg.msg_serialize().unwrap(); + assert_eq!( + bytes, + [ + 0x00, 0x01, // operation + 0x98, 0x76, // port + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // addr + 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, // addr + ] + ); + } + + #[test] + fn test_msg_ack() { + let msg = FirewallMessage::new_ack(); + assert_eq!(msg.operation(), FirewallOperation::Ack); + assert_eq!(msg.port(), None); + assert_eq!(msg.addr(), None); + check_ser_de(&msg); + + let bytes = msg.msg_serialize().unwrap(); + assert_eq!( + bytes, + [ + 0x00, 0x02, // operation + 0x00, 0x00, // port + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // addr + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // addr + ] + ); + } + + #[test] + fn test_msg_nack() { + let msg = FirewallMessage::new_nack(); + assert_eq!(msg.operation(), FirewallOperation::Nack); + assert_eq!(msg.port(), None); + assert_eq!(msg.addr(), None); + check_ser_de(&msg); + + let bytes = msg.msg_serialize().unwrap(); + assert_eq!( + bytes, + [ + 0x00, 0x03, // operation + 0x00, 0x00, // port + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // addr + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // addr + ] + ); + } +} + +// vim: ts=4 sw=4 expandtab diff --git a/letmein-seccomp/Cargo.toml b/letmein-seccomp/Cargo.toml new file mode 100644 index 0000000..83f8f0d --- /dev/null +++ b/letmein-seccomp/Cargo.toml @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- + +[package] +name = "letmein-seccomp" +description = "Authenticated port knocking - Seccomp wrapper" +version = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +license = { workspace = true } +authors = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } +readme = "README.md" +categories = { workspace = true } +keywords = { workspace = true } +build = "build.rs" + +[dependencies] +anyhow = { workspace = true } +libc = { workspace = true } +seccompiler = { workspace = true } + +[build-dependencies] +autocfg = { workspace = true } + +# vim: ts=4 sw=4 expandtab diff --git a/letmein-seccomp/README.md b/letmein-seccomp/README.md new file mode 100644 index 0000000..d4c796c --- /dev/null +++ b/letmein-seccomp/README.md @@ -0,0 +1,15 @@ +# letmein - Authenticated port knocking + +[Homepage](https://bues.ch/h/letmein) + +[Git repository](https://bues.ch/cgit/letmein.git) + +[Github repository](https://github.com/mbuesch/letmein) + +This is a library crate for the `letmein` application. + +# License + +Copyright (c) 2024 Michael Büsch + +Licensed under the Apache License version 2.0 or the MIT license, at your option. diff --git a/letmein-seccomp/build.rs b/letmein-seccomp/build.rs new file mode 100644 index 0000000..0422ef8 --- /dev/null +++ b/letmein-seccomp/build.rs @@ -0,0 +1,16 @@ +// -*- coding: utf-8 -*- +// +// Copyright (C) 2024 Michael Büsch +// +// Licensed under the Apache License version 2.0 +// or the MIT license, at your option. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +fn main() { + let ac = autocfg::new(); + ac.emit_path_cfg("libc::SYS_mmap", "has_SYS_mmap"); + ac.emit_path_cfg("libc::SYS_mmap2", "has_SYS_mmap2"); + autocfg::rerun_path("build.rs"); +} + +// vim: ts=4 sw=4 expandtab diff --git a/letmein-seccomp/src/lib.rs b/letmein-seccomp/src/lib.rs new file mode 100644 index 0000000..748bc51 --- /dev/null +++ b/letmein-seccomp/src/lib.rs @@ -0,0 +1,163 @@ +// -*- coding: utf-8 -*- +// +// Copyright (C) 2024 Michael Büsch +// +// Licensed under the Apache License version 2.0 +// or the MIT license, at your option. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +#![forbid(unsafe_code)] + +#[cfg(not(any(target_os = "linux", target_os = "android")))] +std::compile_error!("letmeind server and letmein-seccomp do not support non-Linux platforms."); + +use anyhow::{self as ah, Context as _}; +use seccompiler::{ + apply_filter_all_threads, BpfProgram, SeccompAction, SeccompFilter, SeccompRule, +}; +use std::{collections::BTreeMap, env::consts::ARCH}; + +pub fn seccomp_supported() -> bool { + cfg!(any(target_arch = "x86_64", target_arch = "aarch64")) +} + +#[derive(Clone, Debug)] +pub enum Allow { + Mmap, + Mprotect, + UnixConnect, + TcpAccept, + Read, + Write, + Recv, + Send, + SignalReturn, +} + +#[derive(Clone, Debug)] +pub enum Action { + Kill, + Log, +} + +pub struct Filter(BpfProgram); + +pub fn seccomp_compile(allow: &[Allow], deny_action: Action) -> ah::Result { + seccomp_compile_for_arch(allow, deny_action, ARCH) +} + +macro_rules! sys { + ($ident:ident) => { + libc::$ident as i64 + }; +} + +pub fn seccomp_compile_for_arch( + allow: &[Allow], + deny_action: Action, + arch: &str, +) -> ah::Result { + let mut rules: BTreeMap> = [ + (sys!(SYS_brk), vec![]), + (sys!(SYS_close), vec![]), + (sys!(SYS_close_range), vec![]), + (sys!(SYS_exit), vec![]), + (sys!(SYS_exit_group), vec![]), + (sys!(SYS_getpid), vec![]), + (sys!(SYS_getrandom), vec![]), + (sys!(SYS_gettid), vec![]), + (sys!(SYS_madvise), vec![]), + (sys!(SYS_munmap), vec![]), + (sys!(SYS_sched_getaffinity), vec![]), + (sys!(SYS_sigaltstack), vec![]), + (sys!(SYS_nanosleep), vec![]), + (sys!(SYS_clock_nanosleep), vec![]), + ] + .into(); + + let add_read_write_rules = |rules: &mut BTreeMap<_, _>| { + rules.insert(sys!(SYS_epoll_create1), vec![]); + rules.insert(sys!(SYS_epoll_ctl), vec![]); + rules.insert(sys!(SYS_epoll_pwait2), vec![]); + rules.insert(sys!(SYS_epoll_wait), vec![]); + rules.insert(sys!(SYS_lseek), vec![]); + rules.insert(sys!(SYS_ppoll), vec![]); + rules.insert(sys!(SYS_pselect6), vec![]); + }; + + for allow in allow { + match *allow { + Allow::Mmap => { + #[cfg(has_SYS_mmap)] + rules.insert(sys!(SYS_mmap), vec![]); + #[cfg(has_SYS_mmap2)] + rules.insert(sys!(SYS_mmap2), vec![]); + rules.insert(sys!(SYS_mremap), vec![]); + rules.insert(sys!(SYS_munmap), vec![]); + } + Allow::Mprotect => { + rules.insert(sys!(SYS_mprotect), vec![]); + } + Allow::UnixConnect => { + rules.insert(sys!(SYS_connect), vec![]); + rules.insert(sys!(SYS_socket), vec![]); //TODO: Restrict to AF_UNIX + rules.insert(sys!(SYS_getsockopt), vec![]); + } + Allow::TcpAccept => { + rules.insert(sys!(SYS_accept4), vec![]); + rules.insert(sys!(SYS_socket), vec![]); //TODO: Restrict to AF_UNIX + rules.insert(sys!(SYS_getsockopt), vec![]); + } + Allow::Read => { + rules.insert(sys!(SYS_pread64), vec![]); + rules.insert(sys!(SYS_preadv2), vec![]); + rules.insert(sys!(SYS_read), vec![]); + rules.insert(sys!(SYS_readv), vec![]); + add_read_write_rules(&mut rules); + } + Allow::Write => { + rules.insert(sys!(SYS_fdatasync), vec![]); + rules.insert(sys!(SYS_fsync), vec![]); + rules.insert(sys!(SYS_pwrite64), vec![]); + rules.insert(sys!(SYS_pwritev2), vec![]); + rules.insert(sys!(SYS_write), vec![]); + rules.insert(sys!(SYS_writev), vec![]); + add_read_write_rules(&mut rules); + } + Allow::Recv => { + rules.insert(sys!(SYS_recvfrom), vec![]); + rules.insert(sys!(SYS_recvmsg), vec![]); + rules.insert(sys!(SYS_recvmmsg), vec![]); + } + Allow::Send => { + rules.insert(sys!(SYS_sendto), vec![]); + rules.insert(sys!(SYS_sendmsg), vec![]); + rules.insert(sys!(SYS_sendmmsg), vec![]); + } + Allow::SignalReturn => { + rules.insert(sys!(SYS_rt_sigreturn), vec![]); + } + } + } + + let filter = SeccompFilter::new( + rules, + match deny_action { + Action::Kill => SeccompAction::KillProcess, + Action::Log => SeccompAction::Log, + }, + SeccompAction::Allow, + arch.try_into().context("Unsupported CPU ARCH")?, + ) + .context("Create seccomp filter")?; + + let filter: BpfProgram = filter.try_into().context("Seccomp to BPF")?; + + Ok(Filter(filter)) +} + +pub fn seccomp_install(filter: Filter) -> ah::Result<()> { + apply_filter_all_threads(&filter.0).context("Apply seccomp filter") +} + +// vim: ts=4 sw=4 expandtab diff --git a/letmein-systemd/Cargo.toml b/letmein-systemd/Cargo.toml index 89b7eb5..5dd39c5 100644 --- a/letmein-systemd/Cargo.toml +++ b/letmein-systemd/Cargo.toml @@ -17,6 +17,7 @@ keywords = { workspace = true } [features] default = [] tcp = [] +unix = [] [dependencies] anyhow = { workspace = true } diff --git a/letmein-systemd/src/lib.rs b/letmein-systemd/src/lib.rs index 648a4c4..0f5414e 100644 --- a/letmein-systemd/src/lib.rs +++ b/letmein-systemd/src/lib.rs @@ -13,18 +13,23 @@ std::compile_error!("letmeind server and letmein-systemd do not support non-Linu use anyhow as ah; -#[cfg(feature = "tcp")] +#[cfg(any(feature = "tcp", feature = "unix"))] use anyhow::{format_err as err, Context as _}; -#[cfg(feature = "tcp")] +#[cfg(any(feature = "tcp", feature = "unix"))] use std::{ mem::size_of_val, - net::TcpListener, os::fd::{FromRawFd as _, RawFd}, }; -/// Check if the passed raw `fd` is a socket. #[cfg(feature = "tcp")] +use std::net::TcpListener; + +#[cfg(feature = "unix")] +use std::os::unix::net::UnixListener; + +/// Check if the passed raw `fd` is a socket. +#[cfg(any(feature = "tcp", feature = "unix"))] fn is_socket(fd: RawFd) -> bool { let mut stat: libc::stat64 = unsafe { std::mem::zeroed() }; let ret = unsafe { libc::fstat64(fd, &mut stat) }; @@ -40,7 +45,7 @@ fn is_socket(fd: RawFd) -> bool { /// Get the socket type of the passed socket `fd`. /// /// SAFETY: The passed `fd` must be a socket `fd`. -#[cfg(feature = "tcp")] +#[cfg(any(feature = "tcp", feature = "unix"))] unsafe fn get_socket_type(fd: RawFd) -> Option { let mut sotype: libc::c_int = 0; let mut len: libc::socklen_t = size_of_val(&sotype) as _; @@ -63,7 +68,7 @@ unsafe fn get_socket_type(fd: RawFd) -> Option { /// Get the socket family of the passed socket `fd`. /// /// SAFETY: The passed `fd` must be a socket `fd`. -#[cfg(feature = "tcp")] +#[cfg(any(feature = "tcp", feature = "unix"))] unsafe fn get_socket_family(fd: RawFd) -> Option { let mut saddr: libc::sockaddr = unsafe { std::mem::zeroed() }; let mut len: libc::socklen_t = size_of_val(&saddr) as _; @@ -86,6 +91,16 @@ fn is_tcp_socket(fd: RawFd) -> bool { } } +#[cfg(feature = "unix")] +fn is_unix_socket(fd: RawFd) -> bool { + // SAFETY: Check if `fd` is a socket before using the socket functions. + unsafe { + is_socket(fd) + && get_socket_type(fd) == Some(libc::SOCK_STREAM) + && get_socket_family(fd) == Some(libc::AF_UNIX) + } +} + /// Create a new [TcpListener] with the socket provided by systemd. /// /// All environment variables related to this operation will be cleared. @@ -106,6 +121,26 @@ pub fn tcp_from_systemd() -> ah::Result> { Ok(None) } +/// Create a new [UnixListener] with the socket provided by systemd. +/// +/// All environment variables related to this operation will be cleared. +#[cfg(feature = "unix")] +pub fn unix_from_systemd() -> ah::Result> { + if sd_notify::booted().unwrap_or(false) { + for fd in sd_notify::listen_fds().context("Systemd listen_fds")? { + if is_unix_socket(fd) { + // SAFETY: + // The fd from systemd is good and lives for the lifetime of the program. + return Ok(Some(unsafe { UnixListener::from_raw_fd(fd) })); + } + } + return Err(err!( + "Booted with systemd, but no Unix listen_fds received from systemd." + )); + } + Ok(None) +} + /// Notify ready-status to systemd. /// /// All environment variables related to this operation will be cleared. diff --git a/letmeind/Cargo.toml b/letmeind/Cargo.toml index 93ed7fb..1ade976 100644 --- a/letmeind/Cargo.toml +++ b/letmeind/Cargo.toml @@ -18,9 +18,10 @@ keywords = { workspace = true } anyhow = { workspace = true } clap = { workspace = true, features = [ "derive" ] } letmein-conf = { workspace = true } +letmein-fwproto = { workspace = true } letmein-proto = { workspace = true } +letmein-seccomp = { workspace = true } letmein-systemd = { workspace = true, features = [ "tcp" ] } -nftables = { workspace = true } -tokio = { workspace = true, features = [ "rt", "net", "macros", "signal", "sync", "time" ] } +tokio = { workspace = true, features = [ "rt", "net", "macros", "signal", "sync" ] } # vim: ts=4 sw=4 expandtab diff --git a/letmeind/letmeind.service b/letmeind/letmeind.service index 5a5b5e1..da796f4 100644 --- a/letmeind/letmeind.service +++ b/letmeind/letmeind.service @@ -1,19 +1,19 @@ [Unit] Description=letmeind daemon -Requires=nftables.service -After=nftables.service +Requires=letmeinfwd.service +After=letmeinfwd.service StartLimitIntervalSec=0 [Service] Type=notify NotifyAccess=main -ExecStart=/opt/letmein/bin/letmeind +ExecStart=/opt/letmein/bin/letmeind --seccomp=kill ExecReload=/bin/kill -HUP $MAINPID StandardOutput=journal StandardError=journal Restart=on-failure RestartSec=10 -User=root -Group=root +User=letmeind +Group=letmeind Nice=0 #Environment=RUST_BACKTRACE=1 diff --git a/letmeind/letmeind.socket b/letmeind/letmeind.socket index c27da45..6788791 100644 --- a/letmeind/letmeind.socket +++ b/letmeind/letmeind.socket @@ -1,7 +1,7 @@ [Unit] Description=letmeind daemon socket -Requires=letmeind.service nftables.service -After=nftables.service +Requires=letmeind.service letmeinfwd.service +After=letmeinfwd.service PartOf=letmeind.service [Socket] diff --git a/letmeind/src/firewall_client.rs b/letmeind/src/firewall_client.rs new file mode 100644 index 0000000..e887257 --- /dev/null +++ b/letmeind/src/firewall_client.rs @@ -0,0 +1,54 @@ +// -*- coding: utf-8 -*- +// +// Copyright (C) 2024 Michael Büsch +// +// Licensed under the Apache License version 2.0 +// or the MIT license, at your option. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use anyhow::{self as ah, format_err as err, Context as _}; +use letmein_fwproto::{FirewallMessage, FirewallOperation, SOCK_FILE}; +use std::{net::IpAddr, path::Path}; +use tokio::net::UnixStream; + +pub struct FirewallClient { + stream: UnixStream, +} + +impl FirewallClient { + /// Connect to the firewall daemon via Unix socket. + pub async fn new(rundir: &Path) -> ah::Result { + let sock_path = rundir.join(SOCK_FILE); + let stream = UnixStream::connect(sock_path) + .await + .context("Connect to Unix socket")?; + Ok(Self { stream }) + } + + /// Send a request to open a firewall `port` for the specified `addr`. + pub async fn open_port(&mut self, addr: IpAddr, port: u16) -> ah::Result<()> { + // Send an open-port request to the firewall daemon. + FirewallMessage::new_open(addr, port) + .send(&mut self.stream) + .await + .context("Send port-open message")?; + + // Receive the open-port reply. + let Some(msg_reply) = FirewallMessage::recv(&mut self.stream) + .await + .context("Receive port-open reply")? + else { + return Err(err!("Connection terminated")); + }; + + match msg_reply.operation() { + FirewallOperation::Ack => Ok(()), + FirewallOperation::Nack => Err(err!("The firewall rejected the port-open request")), + FirewallOperation::OpenV4 | FirewallOperation::OpenV6 => { + Err(err!("Received invalid reply")) + } + } + } +} + +// vim: ts=4 sw=4 expandtab diff --git a/letmeind/src/main.rs b/letmeind/src/main.rs index 6c27f2d..31f828f 100644 --- a/letmeind/src/main.rs +++ b/letmeind/src/main.rs @@ -9,37 +9,112 @@ #![forbid(unsafe_code)] #[cfg(not(any(target_os = "linux", target_os = "android")))] -std::compile_error!("letmeind server and letmein-systemd do not support non-Linux platforms."); +std::compile_error!("letmeind server does not support non-Linux platforms."); -mod firewall; +mod firewall_client; mod processor; mod server; -use crate::{ - firewall::{nftables::NftFirewall, FirewallMaintain}, - processor::Processor, - server::Server, -}; +use crate::{processor::Processor, server::Server}; use anyhow::{self as ah, format_err as err, Context as _}; -use clap::Parser; +use clap::{Parser, ValueEnum}; use letmein_conf::{Config, ConfigVariant, INSTALL_PREFIX, SERVER_CONF_PATH}; -use std::{path::PathBuf, sync::Arc, time::Duration}; +use letmein_seccomp::{ + seccomp_compile, seccomp_install, seccomp_supported, Action as SeccompAction, + Allow as SeccompAllow, +}; +use std::{path::PathBuf, sync::Arc}; use tokio::{ signal::unix::{signal, SignalKind}, - sync::{self, Mutex, RwLock, RwLockReadGuard, Semaphore}, - task, time, + sync::{self, RwLock, RwLockReadGuard, Semaphore}, + task, }; -const FW_MAINTAIN_PERIOD: Duration = Duration::from_millis(5000); - pub type ConfigRef<'a> = RwLockReadGuard<'a, Config>; +#[derive(ValueEnum, Debug, Clone, PartialEq, Eq)] +enum SeccompOpt { + /// Seccomp is disabled (default). + Off, + + /// Seccomp is enabled with logging only. + /// + /// The event will be logged, if a syscall is called that is not allowed. + /// See the Linux kernel logs for seccomp audit messages. + Log, + + /// Seccomp is enabled with killing (recommended). + /// + /// The process will be killed, if a syscall is called that is not allowed. + Kill, +} + +impl From for SeccompAction { + fn from(seccomp: SeccompOpt) -> SeccompAction { + match seccomp { + SeccompOpt::Off | SeccompOpt::Log => SeccompAction::Log, + SeccompOpt::Kill => SeccompAction::Kill, + } + } +} + +impl std::fmt::Display for SeccompOpt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + match self { + Self::Off => write!(f, "Off"), + Self::Log => write!(f, "Logging only"), + Self::Kill => write!(f, "Process killing"), + } + } +} + +impl SeccompOpt { + fn install_rules(&self) -> ah::Result<()> { + match self { + SeccompOpt::Log | SeccompOpt::Kill => { + if seccomp_supported() { + println!("Seccomp mode: {}", self); + seccomp_install( + seccomp_compile( + &[ + SeccompAllow::Mmap, + SeccompAllow::Mprotect, + SeccompAllow::Read, + SeccompAllow::Write, + SeccompAllow::Recv, + SeccompAllow::Send, + SeccompAllow::TcpAccept, + SeccompAllow::UnixConnect, + SeccompAllow::SignalReturn, + ], + self.clone().into(), + ) + .context("Compile seccomp filter")?, + ) + .context("Install seccomp filter")?; + } else { + println!( + "WARNING: Not using seccomp. \ + Letmein does not support seccomp on this architecture, yet." + ); + } + } + SeccompOpt::Off => (), + } + Ok(()) + } +} + #[derive(Parser, Debug, Clone)] struct Opts { /// Override the default path to the configuration file. #[arg(short, long)] config: Option, + /// The run directory for runtime data. + #[arg(long, default_value = "/run")] + rundir: PathBuf, + /// Maximum number of simultaneous connections. #[arg(short, long, default_value = "8")] num_connections: usize, @@ -50,6 +125,13 @@ struct Opts { /// even if a systemd socket has been passed to the application. #[arg(long, default_value = "false")] no_systemd: bool, + + /// Enable Linux 'seccomp' to security harden letmeind. + /// + /// Enabling 'seccomp' in 'kill' mode restricts the number of syscalls + /// available to letmeind to just the ones that are absolutely required. + #[arg(long, default_value = "off")] + seccomp: SeccompOpt, } impl Opts { @@ -64,41 +146,40 @@ impl Opts { #[tokio::main(flavor = "current_thread")] async fn main() -> ah::Result<()> { - let opts = Opts::parse(); + let opts = Arc::new(Opts::parse()); let mut conf = Config::new(ConfigVariant::Server); conf.load(&opts.get_config()) .context("Configuration file")?; let conf = Arc::new(RwLock::new(conf)); - let fw = Arc::new(Mutex::new(NftFirewall::new(&conf.read().await).await?)); - let mut sigterm = signal(SignalKind::terminate()).unwrap(); let mut sigint = signal(SignalKind::interrupt()).unwrap(); let mut sighup = signal(SignalKind::hangup()).unwrap(); let (exit_sock_tx, mut exit_sock_rx) = sync::mpsc::channel(1); - let (exit_fw_tx, mut exit_fw_rx) = sync::mpsc::channel(1); let srv = Server::new(&conf.read().await, opts.no_systemd) .await .context("Server init")?; + opts.seccomp.install_rules()?; + // Task: Socket handler. let conf_clone = Arc::clone(&conf); - let fw_clone = Arc::clone(&fw); + let opts_clone = Arc::clone(&opts); task::spawn(async move { - let conn_semaphore = Semaphore::new(opts.num_connections); + let conn_semaphore = Semaphore::new(opts_clone.num_connections); loop { let conf = Arc::clone(&conf_clone); - let fw = Arc::clone(&fw_clone); + let opts = Arc::clone(&opts_clone); match srv.accept().await { Ok(conn) => { // Socket connection handler. if let Ok(_permit) = conn_semaphore.acquire().await { task::spawn(async move { let conf = conf.read().await; - let mut proc = Processor::new(conn, &conf, fw); + let mut proc = Processor::new(conn, &conf, &opts.rundir); if let Err(e) = proc.run().await { eprintln!("Client error: {e}"); } @@ -113,24 +194,8 @@ async fn main() -> ah::Result<()> { } }); - // Task: Firewall. - let conf_clone = Arc::clone(&conf); - let fw_clone = Arc::clone(&fw); - task::spawn(async move { - let mut interval = time::interval(FW_MAINTAIN_PERIOD); - loop { - interval.tick().await; - let conf = conf_clone.read().await; - let mut fw = fw_clone.lock().await; - if let Err(e) = fw.maintain(&conf).await { - let _ = exit_fw_tx.send(Err(e)).await; - break; - } - } - }); - // Task: Main loop. - let mut exitcode; + let exitcode; loop { tokio::select! { _ = sigterm.recv() => { @@ -143,18 +208,16 @@ async fn main() -> ah::Result<()> { break; } _ = sighup.recv() => { - println!("SIGHUP: Reloading."); - { - let mut conf = conf.write().await; - if let Err(e) = conf.load(&opts.get_config()) { - eprintln!("Failed to load configuration file: {e}"); + match opts.seccomp { + SeccompOpt::Log | SeccompOpt::Kill => { + eprintln!("SIGHUP: Error: Reloading not possible with --seccomp enabled."); } - } - { - let conf = conf.read().await; - let mut fw = fw.lock().await; - if let Err(e) = fw.reload(&conf).await { - eprintln!("Failed to reload filewall rules: {e}"); + SeccompOpt::Off => { + println!("SIGHUP: Reloading."); + let mut conf = conf.write().await; + if let Err(e) = conf.load(&opts.get_config()) { + eprintln!("Failed to load configuration file: {e}"); + } } } } @@ -162,23 +225,6 @@ async fn main() -> ah::Result<()> { exitcode = code.unwrap_or_else(|| Err(err!("Unknown error code."))); break; } - code = exit_fw_rx.recv() => { - exitcode = code.unwrap_or_else(|| Err(err!("Unknown error code."))); - break; - } - } - } - - // Exiting... - // Try to remove all firewall rules. - { - let conf = conf.read().await; - let mut fw = fw.lock().await; - if let Err(e) = fw.clear(&conf).await { - eprintln!("WARNING: Failed to remove firewall rules: {e}"); - if exitcode.is_ok() { - exitcode = Err(err!("Failed to remove firewall rules")); - } } } diff --git a/letmeind/src/processor.rs b/letmeind/src/processor.rs index 76f6f4e..987b222 100644 --- a/letmeind/src/processor.rs +++ b/letmeind/src/processor.rs @@ -6,27 +6,26 @@ // or the MIT license, at your option. // SPDX-License-Identifier: Apache-2.0 OR MIT -use crate::{firewall::FirewallOpen, server::ConnectionOps, ConfigRef}; +use crate::{firewall_client::FirewallClient, server::ConnectionOps, ConfigRef}; use anyhow::{self as ah, format_err as err}; use letmein_conf::Resource; use letmein_proto::{Message, Operation, ResourceId, UserId}; -use std::sync::Arc; -use tokio::sync::Mutex; +use std::path::Path; -pub struct Processor<'a, C, F> { +pub struct Processor<'a, C> { conn: C, conf: &'a ConfigRef<'a>, - fw: Arc>, + rundir: &'a Path, user_id: Option, resource_id: Option, } -impl<'a, C: ConnectionOps, F: FirewallOpen> Processor<'a, C, F> { - pub fn new(conn: C, conf: &'a ConfigRef<'a>, fw: Arc>) -> Self { +impl<'a, C: ConnectionOps> Processor<'a, C> { + pub fn new(conn: C, conf: &'a ConfigRef<'a>, rundir: &'a Path) -> Self { Self { conn, conf, - fw, + rundir, user_id: None, resource_id: None, } @@ -130,9 +129,20 @@ impl<'a, C: ConnectionOps, F: FirewallOpen> Processor<'a, C, F> { // Reconfigure the firewall. match resource { Resource::Port { port, users: _ } => { - let mut fw = self.fw.lock().await; - fw.open_port(self.conf, self.conn.addr().ip(), *port) - .await?; + // Connect to letmeinfwd unix socket. + let mut fw = match FirewallClient::new(self.rundir).await { + Err(e) => { + let _ = self.send_go_away().await; + return Err(err!("Failed to connect to letmeinfwd: {e}")); + } + Ok(fw) => fw, + }; + + // Send an open-port request to letmeinfwd. + if let Err(e) = fw.open_port(self.conn.addr().ip(), *port).await { + let _ = self.send_go_away().await; + return Err(err!("letmeinfwd firewall open: {e}")); + } } } diff --git a/letmeinfwd/Cargo.toml b/letmeinfwd/Cargo.toml new file mode 100644 index 0000000..5a8251d --- /dev/null +++ b/letmeinfwd/Cargo.toml @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- + +[package] +name = "letmeinfwd" +description = "Authenticated port knocking - Firewall backend daemon" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +authors = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } +readme = "README.md" +categories = { workspace = true } +keywords = { workspace = true } + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +letmein-conf = { workspace = true } +letmein-fwproto = { workspace = true } +letmein-systemd = { workspace = true, features = [ "unix" ] } +libc = { workspace = true } +nftables = { workspace = true } +tokio = { workspace = true, features = [ "rt", "net", "macros", "signal", "sync", "time" ] } +user_lookup = { workspace = true, features = [ "async" ] } + +# vim: ts=4 sw=4 expandtab diff --git a/letmeinfwd/README.md b/letmeinfwd/README.md new file mode 100644 index 0000000..d4c796c --- /dev/null +++ b/letmeinfwd/README.md @@ -0,0 +1,15 @@ +# letmein - Authenticated port knocking + +[Homepage](https://bues.ch/h/letmein) + +[Git repository](https://bues.ch/cgit/letmein.git) + +[Github repository](https://github.com/mbuesch/letmein) + +This is a library crate for the `letmein` application. + +# License + +Copyright (c) 2024 Michael Büsch + +Licensed under the Apache License version 2.0 or the MIT license, at your option. diff --git a/letmeinfwd/letmeinfwd.service b/letmeinfwd/letmeinfwd.service new file mode 100644 index 0000000..030f3d9 --- /dev/null +++ b/letmeinfwd/letmeinfwd.service @@ -0,0 +1,19 @@ +[Unit] +Description=letmeinfwd daemon +Requires=nftables.service +After=nftables.service +StartLimitIntervalSec=0 + +[Service] +Type=notify +NotifyAccess=main +ExecStart=/opt/letmein/bin/letmeinfwd +ExecReload=/bin/kill -HUP $MAINPID +StandardOutput=journal +StandardError=journal +Restart=on-failure +RestartSec=10 +User=root +Group=root +Nice=0 +#Environment=RUST_BACKTRACE=1 diff --git a/letmeinfwd/letmeinfwd.socket b/letmeinfwd/letmeinfwd.socket new file mode 100644 index 0000000..8eed73e --- /dev/null +++ b/letmeinfwd/letmeinfwd.socket @@ -0,0 +1,15 @@ +[Unit] +Description=letmeinfwd daemon socket +Requires=letmeinfwd.service nftables.service +After=nftables.service +PartOf=letmeinfwd.service + +[Socket] +ListenStream=/run/letmeinfwd.sock +Accept=no +SocketUser=root +SocketGroup=root +SocketMode=0666 + +[Install] +WantedBy=sockets.target diff --git a/letmeind/src/firewall.rs b/letmeinfwd/src/firewall.rs similarity index 100% rename from letmeind/src/firewall.rs rename to letmeinfwd/src/firewall.rs diff --git a/letmeind/src/firewall/nftables.rs b/letmeinfwd/src/firewall/nftables.rs similarity index 100% rename from letmeind/src/firewall/nftables.rs rename to letmeinfwd/src/firewall/nftables.rs diff --git a/letmeinfwd/src/main.rs b/letmeinfwd/src/main.rs new file mode 100644 index 0000000..e262020 --- /dev/null +++ b/letmeinfwd/src/main.rs @@ -0,0 +1,260 @@ +// -*- coding: utf-8 -*- +// +// Copyright (C) 2024 Michael Büsch +// +// Licensed under the Apache License version 2.0 +// or the MIT license, at your option. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +#![forbid(unsafe_code)] + +#[cfg(not(any(target_os = "linux", target_os = "android")))] +std::compile_error!("letmeind server and letmeinfwd do not support non-Linux platforms."); + +mod firewall; +mod server; + +use crate::{ + firewall::{nftables::NftFirewall, FirewallMaintain, FirewallOpen}, + server::{FirewallConnection, FirewallConnectionOps, FirewallServer}, +}; +use anyhow::{self as ah, format_err as err, Context as _}; +use clap::Parser; +use letmein_conf::{Config, ConfigVariant, INSTALL_PREFIX, SERVER_CONF_PATH}; +use letmein_fwproto::{FirewallMessage, FirewallOperation}; +use std::{net::IpAddr, path::PathBuf, sync::Arc, time::Duration}; +use tokio::{ + signal::unix::{signal, SignalKind}, + sync::{self, Mutex, RwLock, RwLockReadGuard, Semaphore}, + task, time, +}; + +const FW_MAINTAIN_PERIOD: Duration = Duration::from_millis(5000); + +pub type ConfigRef<'a> = RwLockReadGuard<'a, Config>; + +/// Some basic address sanity checks. +fn addr_check(addr: &IpAddr) -> bool { + match addr { + IpAddr::V4(addr) => { + let addr = u32::from_be_bytes(addr.octets()); + addr != 0 && addr != u32::MAX + } + IpAddr::V6(addr) => { + let addr = u128::from_be_bytes(addr.octets()); + addr != 0 && addr != u128::MAX + } + } +} + +/// Handle the firewall daemon unix socket communication. +async fn handle_unix_socket_message( + mut conn: FirewallConnection, + conf: &ConfigRef<'_>, + fw: Arc>, +) -> ah::Result<()> { + let Some(msg) = conn.recv_msg().await? else { + return Err(err!("Disconnected.")); + }; + match msg.operation() { + FirewallOperation::OpenV4 | FirewallOperation::OpenV6 => { + // Get the address from the socket message. + let Some(addr) = msg.addr() else { + conn.send_msg(FirewallMessage::new_nack()).await?; + return Err(err!("No addr.")); + }; + + // Check if addr is valid. + if !addr_check(&addr) { + conn.send_msg(FirewallMessage::new_nack()).await?; + return Err(err!("Invalid addr.")); + } + + // Get the port from the socket message. + let Some(port) = msg.port() else { + conn.send_msg(FirewallMessage::new_nack()).await?; + return Err(err!("No port.")); + }; + + // Check if the port is actually configured. + if conf.resource_id_by_port(port, None).is_none() { + // Whoops, letmeind should never send us a request for an + // unconfigured port. Did some other process write to the unix socket? + conn.send_msg(FirewallMessage::new_nack()).await?; + return Err(err!("The port {port} is not configured in letmeind.conf.")); + } + + // Open the firewall. + let ok = { + let mut fw = fw.lock().await; + fw.open_port(conf, addr, port).await.is_ok() + }; + + if ok { + conn.send_msg(FirewallMessage::new_ack()).await?; + } else { + conn.send_msg(FirewallMessage::new_nack()).await?; + } + } + FirewallOperation::Ack | FirewallOperation::Nack => { + return Err(err!("Received invalid message")); + } + } + Ok(()) +} + +#[derive(Parser, Debug, Clone)] +struct Opts { + /// Override the default path to the configuration file. + #[arg(short, long)] + config: Option, + + /// The run directory for runtime data. + #[arg(long, default_value = "/run")] + rundir: PathBuf, + + /// Maximum number of simultaneous connections. + #[arg(short, long, default_value = "8")] + num_connections: usize, + + /// Force-disable use of systemd socket. + /// + /// Do not use systemd socket, + /// even if a systemd socket has been passed to the application. + #[arg(long, default_value = "false")] + no_systemd: bool, +} + +impl Opts { + pub fn get_config(&self) -> PathBuf { + if let Some(config) = &self.config { + config.clone() + } else { + format!("{INSTALL_PREFIX}{SERVER_CONF_PATH}").into() + } + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> ah::Result<()> { + let opts = Opts::parse(); + + let mut conf = Config::new(ConfigVariant::Server); + conf.load(&opts.get_config()) + .context("Configuration file")?; + let conf = Arc::new(RwLock::new(conf)); + + let fw = Arc::new(Mutex::new(NftFirewall::new(&conf.read().await).await?)); + + let mut sigterm = signal(SignalKind::terminate()).unwrap(); + let mut sigint = signal(SignalKind::interrupt()).unwrap(); + let mut sighup = signal(SignalKind::hangup()).unwrap(); + + let (exit_sock_tx, mut exit_sock_rx) = sync::mpsc::channel(1); + let (exit_fw_tx, mut exit_fw_rx) = sync::mpsc::channel(1); + + let srv = FirewallServer::new(opts.no_systemd, &opts.rundir) + .await + .context("Firewall server init")?; + + // Task: Unix socket handler. + let conf_clone = Arc::clone(&conf); + let fw_clone = Arc::clone(&fw); + task::spawn(async move { + let conn_semaphore = Semaphore::new(opts.num_connections); + loop { + let conf = Arc::clone(&conf_clone); + let fw = Arc::clone(&fw_clone); + match srv.accept().await { + Ok(conn) => { + // Socket connection handler. + if let Ok(_permit) = conn_semaphore.acquire().await { + task::spawn(async move { + let conf = conf.read().await; + if let Err(e) = handle_unix_socket_message(conn, &conf, fw).await { + eprintln!("Client error: {e}"); + } + }); + } + } + Err(e) => { + let _ = exit_sock_tx.send(Err(e)).await; + break; + } + } + } + }); + + // Task: Firewall. + let conf_clone = Arc::clone(&conf); + let fw_clone = Arc::clone(&fw); + task::spawn(async move { + let mut interval = time::interval(FW_MAINTAIN_PERIOD); + loop { + interval.tick().await; + let conf = conf_clone.read().await; + let mut fw = fw_clone.lock().await; + if let Err(e) = fw.maintain(&conf).await { + let _ = exit_fw_tx.send(Err(e)).await; + break; + } + } + }); + + // Task: Main loop. + let mut exitcode; + loop { + tokio::select! { + _ = sigterm.recv() => { + eprintln!("SIGTERM: Terminating."); + exitcode = Ok(()); + break; + } + _ = sigint.recv() => { + exitcode = Err(err!("Interrupted by SIGINT.")); + break; + } + _ = sighup.recv() => { + println!("SIGHUP: Reloading."); + { + let mut conf = conf.write().await; + if let Err(e) = conf.load(&opts.get_config()) { + eprintln!("Failed to load configuration file: {e}"); + } + } + { + let conf = conf.read().await; + let mut fw = fw.lock().await; + if let Err(e) = fw.reload(&conf).await { + eprintln!("Failed to reload filewall rules: {e}"); + } + } + } + code = exit_sock_rx.recv() => { + exitcode = code.unwrap_or_else(|| Err(err!("Unknown error code."))); + break; + } + code = exit_fw_rx.recv() => { + exitcode = code.unwrap_or_else(|| Err(err!("Unknown error code."))); + break; + } + } + } + + // Exiting... + // Try to remove all firewall rules. + { + let conf = conf.read().await; + let mut fw = fw.lock().await; + if let Err(e) = fw.clear(&conf).await { + eprintln!("WARNING: Failed to remove firewall rules: {e}"); + if exitcode.is_ok() { + exitcode = Err(err!("Failed to remove firewall rules")); + } + } + } + + exitcode +} + +// vim: ts=4 sw=4 expandtab diff --git a/letmeinfwd/src/server.rs b/letmeinfwd/src/server.rs new file mode 100644 index 0000000..0302930 --- /dev/null +++ b/letmeinfwd/src/server.rs @@ -0,0 +1,145 @@ +// -*- coding: utf-8 -*- +// +// Copyright (C) 2024 Michael Büsch +// +// Licensed under the Apache License version 2.0 +// or the MIT license, at your option. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use anyhow::{self as ah, format_err as err, Context as _}; +use letmein_fwproto::{FirewallMessage, SOCK_FILE}; +use letmein_systemd::{systemd_notify_ready, unix_from_systemd}; +use std::{ + fs::{metadata, remove_file}, + os::unix::fs::MetadataExt as _, + path::Path, + time::Duration, +}; +use tokio::net::{UnixListener, UnixStream}; +use user_lookup::async_reader::{GroupReader, PasswdReader}; + +/// Resolve a user name into a UID. +async fn get_uid(user_name: &str) -> ah::Result { + let Some(user) = PasswdReader::new(Duration::from_secs(0)) + .get_by_username(user_name) + .await + .context("Get /etc/passwd user")? + else { + return Err(err!("User '{user_name}' not found in /etc/passwd.")); + }; + Ok(user.uid) +} + +/// Resolve a group name into a GID. +async fn get_gid(group_name: &str) -> ah::Result { + let Some(group) = GroupReader::new(Duration::from_secs(0)) + .get_by_name(group_name) + .await + .context("Get /etc/group group")? + else { + return Err(err!("Group '{group_name}' not found in /etc/group.")); + }; + Ok(group.gid) +} + +pub trait FirewallConnectionOps { + async fn recv_msg(&mut self) -> ah::Result>; + async fn send_msg(&mut self, msg: FirewallMessage) -> ah::Result<()>; +} + +pub struct FirewallConnection { + stream: UnixStream, +} + +impl FirewallConnection { + fn new(stream: UnixStream) -> ah::Result { + Ok(Self { stream }) + } +} + +impl FirewallConnectionOps for FirewallConnection { + async fn recv_msg(&mut self) -> ah::Result> { + FirewallMessage::recv(&mut self.stream).await + } + + async fn send_msg(&mut self, msg: FirewallMessage) -> ah::Result<()> { + msg.send(&mut self.stream).await + } +} + +pub struct FirewallServer { + listener: UnixListener, + allowed_uid: u32, + allowed_gid: u32, +} + +impl FirewallServer { + pub async fn new(no_systemd: bool, rundir: &Path) -> ah::Result { + let allowed_uid = get_uid("letmeind").await?; + let allowed_gid = get_gid("letmeind").await?; + + if !no_systemd { + if let Some(listener) = unix_from_systemd()? { + println!("Using socket from systemd."); + listener + .set_nonblocking(true) + .context("Set socket non-blocking")?; + let listener = UnixListener::from_std(listener) + .context("Convert std UnixListener to tokio UnixListener")?; + systemd_notify_ready()?; + return Ok(Self { + listener, + allowed_uid, + allowed_gid, + }); + } + } + + // Without systemd. + + // Remove the socket, if it exists. + let sock_path = rundir.join(SOCK_FILE); + if let Ok(meta) = metadata(&sock_path) { + if meta.mode() & libc::S_IFMT == libc::S_IFSOCK { + remove_file(&sock_path).context("Remove existing socket")?; + } + } + // Bind to the Unix socket. + let listener = UnixListener::bind(&sock_path).context("Bind socket")?; + Ok(Self { + listener, + allowed_uid, + allowed_gid, + }) + } + + /// Accept a connection on the Unix socket. + pub async fn accept(&self) -> ah::Result { + let (stream, _addr) = self.listener.accept().await?; + + // Get the credentials of the connected process. + let cred = stream + .peer_cred() + .context("Get Unix socket peer credentials")?; + + // Check if the connected process is letmeind user and group. + if cred.uid() != self.allowed_uid { + return Err(err!( + "The connected uid {} is not letmeind ({}). Rejecting.", + cred.uid(), + self.allowed_uid + )); + } + if cred.gid() != self.allowed_gid { + return Err(err!( + "The connected gid {} is not letmeind ({}). Rejecting.", + cred.gid(), + self.allowed_gid + )); + } + + FirewallConnection::new(stream) + } +} + +// vim: ts=4 sw=4 expandtab