From b4ec1e57c9b80c755f42838f3467c9081368881f 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 | 50 +++ Cargo.toml | 8 + README.md | 51 ++- build.sh | 2 + create-user.sh | 76 ++++ install-server.sh | 54 ++- letmein-conf/src/lib.rs | 59 +++ letmein-fwproto/Cargo.toml | 21 ++ letmein-fwproto/README.md | 15 + letmein-fwproto/src/lib.rs | 342 ++++++++++++++++++ letmein-seccomp/Cargo.toml | 26 ++ letmein-seccomp/README.md | 15 + letmein-seccomp/build.rs | 27 ++ letmein-seccomp/src/lib.rs | 189 ++++++++++ letmein-systemd/Cargo.toml | 1 + letmein-systemd/src/lib.rs | 47 ++- letmeind/Cargo.toml | 6 +- letmeind/letmeind.conf | 1 + letmeind/letmeind.service | 15 +- letmeind/letmeind.socket | 2 - letmeind/src/firewall_client.rs | 54 +++ letmeind/src/main.rs | 202 +++++++---- letmeind/src/processor.rs | 32 +- letmeind/src/server.rs | 15 +- letmeinfwd/Cargo.toml | 27 ++ letmeinfwd/README.md | 15 + letmeinfwd/letmeinfwd.service | 26 ++ letmeinfwd/letmeinfwd.socket | 13 + {letmeind => letmeinfwd}/src/firewall.rs | 0 .../src/firewall/nftables.rs | 0 letmeinfwd/src/main.rs | 293 +++++++++++++++ letmeinfwd/src/server.rs | 232 ++++++++++++ 32 files changed, 1798 insertions(+), 118 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 c1f59fa..f9b2431 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,27 @@ dependencies = [ "anyhow", "clap", "letmein-conf", + "letmein-fwproto", "letmein-proto", + "letmein-seccomp", "letmein-systemd", + "libc", + "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 +782,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 +1050,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "user_lookup" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce218b4b10d1acf525e5798d1014bc9922f0d11c0267ddc4be4c14775124e209" + [[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..8f0f91c 100644 --- a/README.md +++ b/README.md @@ -138,43 +138,46 @@ 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 user `letmeind` and the group `letmeind` have to be present in `/etc/passwd` and `/etc/group`. +It is recommended for this user to not have a shell and home assigned and therefore not be a login-user. + +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 +188,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 691f64c..3724e16 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 $*" @@ -57,20 +69,35 @@ do_chmod() 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 letmeinfwd.service do_systemctl start letmeind.socket + do_systemctl start letmeind.service } install_dirs() @@ -87,16 +114,37 @@ install_dirs() install_conf() { if [ -e /opt/letmein/etc/letmeind.conf ]; then - do_chown root:root /opt/letmein/etc/letmeind.conf + 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 letmeinfwd.socket + do_systemctl enable letmeinfwd.service +} + install_letmeind() { do_install \ @@ -115,6 +163,7 @@ install_letmeind() /etc/systemd/system/ do_systemctl enable letmeind.socket + do_systemctl enable letmeind.service } release="release" @@ -138,6 +187,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..ab24e28 100644 --- a/letmein-conf/src/lib.rs +++ b/letmein-conf/src/lib.rs @@ -60,6 +60,50 @@ impl Resource { } } +/// Seccomp setting. +#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] +pub enum Seccomp { + /// Seccomp is disabled (default). + #[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 std::fmt::Display for Seccomp { + 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 std::str::FromStr for Seccomp { + type Err = ah::Error; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().trim() { + "off" => Ok(Self::Off), + "log" => Ok(Self::Log), + "kill" => Ok(Self::Kill), + other => Err(err!( + "Config option 'seccomp = {other}' is not valid. Valid values are: off, log, kill." + )), + } + } +} + fn parse_bool(s: &str) -> ah::Result { let s = s.to_lowercase(); let s = s.trim(); @@ -178,6 +222,13 @@ fn get_port(ini: &Ini) -> ah::Result { Ok(PORT) } +fn get_seccomp(ini: &Ini) -> ah::Result { + if let Some(seccomp) = ini.get("GENERAL", "seccomp") { + return seccomp.parse(); + } + Ok(Default::default()) +} + fn get_keys(ini: &Ini) -> ah::Result> { let mut keys = HashMap::new(); if let Some(options) = ini.options_iter("KEYS") { @@ -280,6 +331,7 @@ pub struct Config { variant: ConfigVariant, debug: bool, port: u16, + seccomp: Seccomp, keys: HashMap, resources: HashMap, default_user: UserId, @@ -320,6 +372,7 @@ impl Config { let debug = get_debug(ini)?; let port = get_port(ini)?; + let seccomp = get_seccomp(ini)?; let keys = get_keys(ini)?; let resources = get_resources(ini)?; if self.variant == ConfigVariant::Client { @@ -334,6 +387,7 @@ impl Config { self.debug = debug; self.port = port; + self.seccomp = seccomp; self.keys = keys; self.resources = resources; self.default_user = default_user; @@ -354,6 +408,11 @@ impl Config { self.port } + /// Get the `seccomp` option from `[GENERAL]` section. + pub fn seccomp(&self) -> Seccomp { + self.seccomp + } + /// Get a key value by key identifier from the `[KEYS]` section. pub fn key(&self, id: UserId) -> Option<&Key> { self.keys.get(&id) 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..c164f22 --- /dev/null +++ b/letmein-fwproto/src/lib.rs @@ -0,0 +1,342 @@ +// -*- 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 + +//! This crate implements the firewall socket protocol +//! for communication between the `letmeind` and `letmeinfwd` daemons. +//! +//! Serializing messages to a raw byte stream and +//! deserializing raw byte stream to a message is implemented here. + +#![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 `operation` and `addr` fields of a firewall control message. +fn addr_to_octets(addr: IpAddr) -> (FirewallOperation, [u8; ADDR_SIZE]) { + match addr { + IpAddr::V4(addr) => { + let o = addr.octets(); + ( + FirewallOperation::OpenV4, + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, o[0], o[1], o[2], o[3]], + ) + } + IpAddr::V6(addr) => (FirewallOperation::OpenV6, addr.octets()), + } +} + +/// Convert a firewall control message `operation` and `addr` fields 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, 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..f7ebb69 --- /dev/null +++ b/letmein-seccomp/build.rs @@ -0,0 +1,27 @@ +// -*- 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 probe_syscall(ac: &autocfg::AutoCfg, name: &str) { + ac.emit_path_cfg(&format!("libc::SYS_{name}"), &format!("has_SYS_{name}")); + println!("cargo:rustc-check-cfg=cfg(has_SYS_{name})"); +} + +fn main() { + let ac = autocfg::new(); + + probe_syscall(&ac, "mmap"); + probe_syscall(&ac, "mmap2"); + probe_syscall(&ac, "futex_waitv"); + probe_syscall(&ac, "futex_wake"); + probe_syscall(&ac, "futex_wait"); + probe_syscall(&ac, "futex_requeue"); + + 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..420ec7a --- /dev/null +++ b/letmein-seccomp/src/lib.rs @@ -0,0 +1,189 @@ +// -*- 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, + Prctl, + Signal, + Futex, +} + +#[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) => {{ + #[allow(clippy::useless_conversion)] + let id: i64 = libc::$ident.into(); + id + }}; +} + +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_gettime), vec![]), + (sys!(SYS_clock_getres), vec![]), + (sys!(SYS_clock_nanosleep), vec![]), + (sys!(SYS_gettimeofday), 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::Prctl => { + //TODO: The arguments should be restricted to what is needed. + rules.insert(sys!(SYS_prctl), vec![]); + } + Allow::Signal => { + rules.insert(sys!(SYS_rt_sigaction), vec![]); + rules.insert(sys!(SYS_rt_sigreturn), vec![]); + rules.insert(sys!(SYS_rt_sigprocmask), vec![]); + } + Allow::Futex => { + rules.insert(sys!(SYS_futex), vec![]); + rules.insert(sys!(SYS_get_robust_list), vec![]); + rules.insert(sys!(SYS_set_robust_list), vec![]); + #[cfg(has_SYS_futex_waitv)] + rules.insert(sys!(SYS_futex_waitv), vec![]); + #[cfg(has_SYS_futex_wake)] + rules.insert(sys!(SYS_futex_wake), vec![]); + #[cfg(has_SYS_futex_wait)] + rules.insert(sys!(SYS_futex_wait), vec![]); + #[cfg(has_SYS_futex_requeue)] + rules.insert(sys!(SYS_futex_requeue), 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..ea60f1c 100644 --- a/letmeind/Cargo.toml +++ b/letmeind/Cargo.toml @@ -18,9 +18,11 @@ 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" ] } +libc = { workspace = true } +tokio = { workspace = true, features = [ "rt", "net", "macros", "signal", "sync" ] } # vim: ts=4 sw=4 expandtab diff --git a/letmeind/letmeind.conf b/letmeind/letmeind.conf index 4fd15af..79be045 100644 --- a/letmeind/letmeind.conf +++ b/letmeind/letmeind.conf @@ -1,6 +1,7 @@ [GENERAL] debug = true port = 5800 +seccomp = off [NFTABLES] family = inet diff --git a/letmeind/letmeind.service b/letmeind/letmeind.service index 5a5b5e1..2a4e783 100644 --- a/letmeind/letmeind.service +++ b/letmeind/letmeind.service @@ -1,7 +1,8 @@ [Unit] Description=letmeind daemon -Requires=nftables.service -After=nftables.service +Requires=letmeinfwd.service +After=letmeinfwd.service +PartOf=letmeind.socket StartLimitIntervalSec=0 [Service] @@ -9,11 +10,17 @@ Type=notify NotifyAccess=main ExecStart=/opt/letmein/bin/letmeind ExecReload=/bin/kill -HUP $MAINPID +RuntimeDirectory=letmeind +RuntimeDirectoryMode=0750 StandardOutput=journal StandardError=journal Restart=on-failure RestartSec=10 -User=root -Group=root +User=letmeind +Group=letmeind Nice=0 #Environment=RUST_BACKTRACE=1 + +[Install] +# Don't do socket-activation. Always start the service. +WantedBy=multi-user.target diff --git a/letmeind/letmeind.socket b/letmeind/letmeind.socket index c27da45..c1df404 100644 --- a/letmeind/letmeind.socket +++ b/letmeind/letmeind.socket @@ -1,7 +1,5 @@ [Unit] Description=letmeind daemon socket -Requires=letmeind.service nftables.service -After=nftables.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..a53763a --- /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("letmeinfwd").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..6a35357 100644 --- a/letmeind/src/main.rs +++ b/letmeind/src/main.rs @@ -9,37 +9,127 @@ #![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 letmein_conf::{Config, ConfigVariant, INSTALL_PREFIX, SERVER_CONF_PATH}; -use std::{path::PathBuf, sync::Arc, time::Duration}; +use letmein_conf::{Config, ConfigVariant, Seccomp, INSTALL_PREFIX, SERVER_CONF_PATH}; +use letmein_seccomp::{ + seccomp_compile, seccomp_install, seccomp_supported, Action as SeccompAction, + Allow as SeccompAllow, +}; +use std::{ + fs::{create_dir_all, metadata, OpenOptions}, + io::Write as _, + os::unix::fs::MetadataExt as _, + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; use tokio::{ + runtime, 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>; +/// Create a directory, if it does not exist already. +fn create_dir_if_not_exists(path: &Path) -> ah::Result<()> { + match metadata(path) { + Err(_) => { + create_dir_all(path)?; + } + Ok(meta) => { + const S_IFMT: u32 = libc::S_IFMT as _; + const S_IFDIR: u32 = libc::S_IFDIR as _; + if (meta.mode() & S_IFMT) != S_IFDIR { + return Err(err!("Path '{path:?}' exists, but is not a directory.")); + } + } + } + Ok(()) +} + +/// Create the /run subdirectory. +fn make_run_subdir(rundir: &Path) -> ah::Result<()> { + let runsubdir = rundir.join("letmeind"); + create_dir_if_not_exists(&runsubdir).context("Create /run subdirectory")?; + Ok(()) +} + +/// Create the PID-file in the /run subdirectory. +fn make_pidfile(rundir: &Path) -> ah::Result<()> { + OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(rundir.join("letmeind/letmeind.pid")) + .context("Open PID-file")? + .write_all(format!("{}\n", std::process::id()).as_bytes()) + .context("Write to PID-file") +} + +fn seccomp_to_action(seccomp: Seccomp) -> SeccompAction { + match seccomp { + Seccomp::Off | Seccomp::Log => SeccompAction::Log, + Seccomp::Kill => SeccompAction::Kill, + } +} + +fn install_seccomp_rules(seccomp: Seccomp) -> ah::Result<()> { + match seccomp { + Seccomp::Log | Seccomp::Kill => { + if seccomp_supported() { + println!("Seccomp mode: {}", seccomp); + seccomp_install( + seccomp_compile( + &[ + SeccompAllow::Mmap, + SeccompAllow::Mprotect, + SeccompAllow::Read, + SeccompAllow::Write, + SeccompAllow::Recv, + SeccompAllow::Send, + SeccompAllow::TcpAccept, + SeccompAllow::UnixConnect, + SeccompAllow::Prctl, + SeccompAllow::Signal, + SeccompAllow::Futex, + ], + seccomp_to_action(seccomp), + ) + .context("Compile seccomp filter")?, + ) + .context("Install seccomp filter")?; + } else { + println!( + "WARNING: Not using seccomp. \ + Letmein does not support seccomp on this architecture, yet." + ); + } + } + Seccomp::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, @@ -62,43 +152,43 @@ impl Opts { } } -#[tokio::main(flavor = "current_thread")] -async fn main() -> ah::Result<()> { - let opts = Opts::parse(); +async fn async_main(opts: Arc) -> ah::Result<()> { + make_run_subdir(&opts.rundir)?; 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")?; + make_pidfile(&opts.rundir)?; + + install_seccomp_rules(conf.read().await.seccomp())?; + // 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 +203,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 +217,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}"); + let mut conf = conf.write().await; + match conf.seccomp() { + Seccomp::Log | Seccomp::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}"); + Seccomp::Off => { + println!("SIGHUP: Reloading."); + if let Err(e) = conf.load(&opts.get_config()) { + eprintln!("Failed to load configuration file: {e}"); + } } } } @@ -162,27 +234,21 @@ 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")); - } } } exitcode } +fn main() -> ah::Result<()> { + let opts = Arc::new(Opts::parse()); + runtime::Builder::new_current_thread() + .thread_keep_alive(Duration::from_millis(0)) + .max_blocking_threads(1) + .enable_all() + .build() + .context("Tokio runtime builder")? + .block_on(async_main(opts)) +} + // vim: ts=4 sw=4 expandtab 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/letmeind/src/server.rs b/letmeind/src/server.rs index e6f923a..4641d8a 100644 --- a/letmeind/src/server.rs +++ b/letmeind/src/server.rs @@ -50,6 +50,7 @@ pub struct Server { impl Server { pub async fn new(conf: &ConfigRef<'_>, no_systemd: bool) -> ah::Result { + // Get socket from systemd? if !no_systemd { if let Some(listener) = tcp_from_systemd()? { println!("Using socket from systemd."); @@ -62,11 +63,15 @@ impl Server { return Ok(Self { listener }); } } - Ok(Self { - listener: TcpListener::bind(("::0", conf.port())) - .await - .context("Bind")?, - }) + + // Without systemd. + + // TCP bind. + let listener = TcpListener::bind(("::0", conf.port())) + .await + .context("Bind")?; + + Ok(Self { listener }) } pub async fn accept(&self) -> ah::Result { diff --git a/letmeinfwd/Cargo.toml b/letmeinfwd/Cargo.toml new file mode 100644 index 0000000..a3f2b0f --- /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 = [ "sync" ] } + +# 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..7c9f70f --- /dev/null +++ b/letmeinfwd/letmeinfwd.service @@ -0,0 +1,26 @@ +[Unit] +Description=letmeinfwd daemon +Requires=letmeinfwd.socket nftables.service +PartOf=letmeinfwd.socket +StartLimitIntervalSec=0 + +[Service] +Type=notify +NotifyAccess=main +ExecStart=/opt/letmein/bin/letmeinfwd +ExecReload=/bin/kill -HUP $MAINPID +RuntimeDirectory=letmeinfwd +RuntimeDirectoryMode=0750 +StandardOutput=journal +StandardError=journal +Restart=on-failure +RestartSec=10 +User=root +Group=letmeind +Nice=0 +#Environment=RUST_BACKTRACE=1 + +[Install] +# We can't do socket-activation. +# This service opens the letmeind communication port. +WantedBy=multi-user.target diff --git a/letmeinfwd/letmeinfwd.socket b/letmeinfwd/letmeinfwd.socket new file mode 100644 index 0000000..32a59a0 --- /dev/null +++ b/letmeinfwd/letmeinfwd.socket @@ -0,0 +1,13 @@ +[Unit] +Description=letmeinfwd daemon socket +PartOf=letmeinfwd.service + +[Socket] +ListenStream=/run/letmeinfwd/letmeinfwd.sock +Accept=no +SocketUser=root +SocketGroup=letmeind +SocketMode=0660 + +[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..eb36db4 --- /dev/null +++ b/letmeinfwd/src/main.rs @@ -0,0 +1,293 @@ +// -*- 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}, + server::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 std::{ + fs::{create_dir_all, metadata, set_permissions, OpenOptions}, + io::Write as _, + os::unix::fs::{chown, MetadataExt as _, PermissionsExt as _}, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicU32, Ordering::Relaxed}, + Arc, + }, + time::Duration, +}; +use tokio::{ + runtime, + signal::unix::{signal, SignalKind}, + sync::{self, Mutex, RwLock, RwLockReadGuard, Semaphore}, + task, time, +}; +use user_lookup::sync_reader::{GroupReader, PasswdReader}; + +const FW_MAINTAIN_PERIOD: Duration = Duration::from_millis(5000); + +static LETMEIND_UID: AtomicU32 = AtomicU32::new(u32::MAX); +static LETMEIND_GID: AtomicU32 = AtomicU32::new(u32::MAX); + +pub type ConfigRef<'a> = RwLockReadGuard<'a, Config>; + +/// Create a directory, if it does not exist already. +fn create_dir_if_not_exists(path: &Path) -> ah::Result<()> { + match metadata(path) { + Err(_) => { + create_dir_all(path)?; + } + Ok(meta) => { + const S_IFMT: u32 = libc::S_IFMT as _; + const S_IFDIR: u32 = libc::S_IFDIR as _; + if (meta.mode() & S_IFMT) != S_IFDIR { + return Err(err!("Path '{path:?}' exists, but is not a directory.")); + } + } + } + Ok(()) +} + +/// Set the uid, gid and the mode of a filesystem element. +pub fn set_owner_mode(path: &Path, uid: u32, gid: u32, mode: u32) -> ah::Result<()> { + let meta = metadata(path).context("Stat path")?; + chown(path, Some(uid), Some(gid)).context("Set path owner")?; + let mut perm = meta.permissions(); + perm.set_mode(mode); + set_permissions(path, perm).context("Set path mode")?; + Ok(()) +} + +/// Create the /run subdirectory. +fn make_run_subdir(rundir: &Path) -> ah::Result<()> { + let runsubdir = rundir.join("letmeinfwd"); + create_dir_if_not_exists(&runsubdir).context("Create /run subdirectory")?; + set_owner_mode( + &runsubdir, + 0, /* root */ + LETMEIND_GID.load(Relaxed), + 0o750, + ) + .context("Set /run subdirectory owner and mode")?; + Ok(()) +} + +/// Resolve a user name into a UID. +fn os_get_uid(user_name: &str) -> ah::Result { + let Some(user) = PasswdReader::new(Duration::from_secs(0)) + .get_by_username(user_name) + .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. +fn os_get_gid(group_name: &str) -> ah::Result { + let Some(group) = GroupReader::new(Duration::from_secs(0)) + .get_by_name(group_name) + .context("Get /etc/group group")? + else { + return Err(err!("Group '{group_name}' not found in /etc/group.")); + }; + Ok(group.gid) +} + +/// Get UIDs and GIDs. +fn read_etc_passwd() -> ah::Result<()> { + LETMEIND_UID.store(os_get_uid("letmeind")?, Relaxed); + LETMEIND_GID.store(os_get_gid("letmeind")?, Relaxed); + Ok(()) +} + +fn make_pidfile(rundir: &Path) -> ah::Result<()> { + OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(rundir.join("letmeinfwd/letmeinfwd.pid")) + .context("Open PID-file")? + .write_all(format!("{}\n", std::process::id()).as_bytes()) + .context("Write to PID-file") +} + +#[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() + } + } +} + +async fn async_main(opts: Arc) -> ah::Result<()> { + read_etc_passwd()?; + make_run_subdir(&opts.rundir)?; + + 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_fw_tx, mut exit_fw_rx) = sync::mpsc::channel(1); + + let srv = FirewallServer::new(opts.no_systemd, &opts.rundir) + .await + .context("Firewall server init")?; + + make_pidfile(&opts.rundir)?; + + // Task: Unix socket handler. + let conf_clone = Arc::clone(&conf); + let opts_clone = Arc::clone(&opts); + let fw_clone = Arc::clone(&fw); + task::spawn(async move { + let conn_semaphore = Semaphore::new(opts_clone.num_connections); + loop { + let conf = Arc::clone(&conf_clone); + let fw = Arc::clone(&fw_clone); + match srv.accept().await { + Ok(mut 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) = conn.handle_message(&conf, fw).await { + eprintln!("Client error: {e}"); + } + }); + } + } + Err(e) => { + eprintln!("Accept connection: {e}"); + } + } + } + }); + + // 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_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 +} + +fn main() -> ah::Result<()> { + let opts = Arc::new(Opts::parse()); + runtime::Builder::new_current_thread() + .thread_keep_alive(Duration::from_millis(0)) + .max_blocking_threads(1) + .enable_all() + .build() + .context("Tokio runtime builder")? + .block_on(async_main(opts)) +} + +// vim: ts=4 sw=4 expandtab diff --git a/letmeinfwd/src/server.rs b/letmeinfwd/src/server.rs new file mode 100644 index 0000000..f628fab --- /dev/null +++ b/letmeinfwd/src/server.rs @@ -0,0 +1,232 @@ +// -*- 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 crate::{ + firewall::{FirewallMaintain, FirewallOpen}, + set_owner_mode, ConfigRef, LETMEIND_GID, LETMEIND_UID, +}; +use anyhow::{self as ah, format_err as err, Context as _}; +use letmein_fwproto::{FirewallMessage, FirewallOperation, SOCK_FILE}; +use letmein_systemd::{systemd_notify_ready, unix_from_systemd}; +use std::{ + fs::{metadata, remove_file, OpenOptions}, + io::Read as _, + net::IpAddr, + os::unix::fs::MetadataExt as _, + path::{Path, PathBuf}, + sync::{atomic::Ordering::Relaxed, Arc}, +}; +use tokio::{ + net::{unix::pid_t, UnixListener, UnixStream}, + sync::Mutex, +}; + +/// Get the actual PID of the `letmeind` daemon process. +fn get_letmeind_pid(rundir: &Path) -> ah::Result { + let mut pid = String::new(); + OpenOptions::new() + .read(true) + .open(rundir.join("letmeind/letmeind.pid")) + .context("Open PID-file of 'letmeind' daemon")? + .read_to_string(&mut pid) + .context("Read PID-file of 'letmeind' daemon")?; + pid.trim() + .parse() + .context("Parse 'letmeind' PID-file string to number") +} + +/// 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 + } + } +} + +pub struct FirewallConnection { + stream: UnixStream, +} + +impl FirewallConnection { + fn new(stream: UnixStream) -> ah::Result { + Ok(Self { stream }) + } + + 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 + } + + /// Handle the firewall daemon unix socket communication. + pub async fn handle_message( + &mut self, + conf: &ConfigRef<'_>, + fw: Arc>, + ) -> ah::Result<()> { + let Some(msg) = self.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 { + self.send_msg(FirewallMessage::new_nack()).await?; + return Err(err!("No addr.")); + }; + + // Check if addr is valid. + if !addr_check(&addr) { + self.send_msg(FirewallMessage::new_nack()).await?; + return Err(err!("Invalid addr.")); + } + + // Get the port from the socket message. + let Some(port) = msg.port() else { + self.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? + self.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 { + self.send_msg(FirewallMessage::new_ack()).await?; + } else { + self.send_msg(FirewallMessage::new_nack()).await?; + } + } + FirewallOperation::Ack | FirewallOperation::Nack => { + return Err(err!("Received invalid message")); + } + } + Ok(()) + } +} + +pub struct FirewallServer { + listener: UnixListener, + rundir: PathBuf, +} + +impl FirewallServer { + pub async fn new(no_systemd: bool, rundir: &Path) -> ah::Result { + // Get socket from systemd? + 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, + rundir: rundir.to_owned(), + }); + } + } + + // Without systemd. + + // Remove the socket, if it exists. + let runsubdir = rundir.join("letmeinfwd"); + let sock_path = runsubdir.join(SOCK_FILE); + if let Ok(meta) = metadata(&sock_path) { + const S_IFMT: u32 = libc::S_IFMT as _; + const S_IFSOCK: u32 = libc::S_IFSOCK as _; + if (meta.mode() & S_IFMT) == S_IFSOCK { + remove_file(&sock_path).context("Remove existing socket")?; + } + } + + // Bind to the Unix socket. + let listener = UnixListener::bind(&sock_path).context("Bind socket")?; + set_owner_mode( + &sock_path, + 0, /* root */ + LETMEIND_GID.load(Relaxed), + 0o660, + ) + .context("Set unix socket owner and mode")?; + + Ok(Self { + listener, + rundir: rundir.to_owned(), + }) + } + + /// 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")?; + + // Only the letmeind service process is allowed to connect. + // Check the PID. + // This check is racy, if the letmeind service is restarted. But that's Ok. + // This is an additional check that is not strictly needed for the security + // concept. The socket is only accessible by the `letmeind` group and user. + let Some(pid) = cred.pid() else { + return Err(err!("The connected pid is not known. Rejecting.")); + }; + let expected_pid = get_letmeind_pid(&self.rundir)?; + if pid != expected_pid { + return Err(err!( + "The connected pid {pid} is not letmeind ({expected_pid}). Rejecting." + )); + } + + // Check if the connected process is letmeind user and group. + // This is an additional check that is not strictly needed for the security + // concept. The socket is only accessible by the `letmeind` group and user. + if cred.uid() != LETMEIND_UID.load(Relaxed) { + return Err(err!( + "The connected uid {} is not letmeind ({}). Rejecting. \ + Please ensure that the 'letmeind' daemon is running as 'letmeind' user.", + cred.uid(), + LETMEIND_UID.load(Relaxed), + )); + } + if cred.gid() != LETMEIND_GID.load(Relaxed) { + return Err(err!( + "The connected gid {} is not letmeind ({}). Rejecting. \ + Please ensure that the 'letmeind' daemon is running as 'letmeind' group.", + cred.gid(), + LETMEIND_GID.load(Relaxed), + )); + } + + FirewallConnection::new(stream) + } +} + +// vim: ts=4 sw=4 expandtab