From 0d2ddd84975d8f7eab4bb4445cc225d6351dc05d Mon Sep 17 00:00:00 2001 From: Thomas Eizinger Date: Fri, 24 Oct 2025 16:14:58 +1100 Subject: [PATCH] feat(gateway): create debian package (#10537) With this PR we add `cargo-deb` to our CI pipeline and build a debian package for the Gateway. The debian package comes with several configuration files that make it easy for admins to start and maintain a Gateway installation: - The embedded systemd unit file is essentially the same one as what we currently install with the install script with some minor modifications. - The token is read from `/etc/firezone/gateway-token` and passed as a systemd credential. This allows us to set the permissions for this file to `0400` and have it owned by `root:root`. - The configuration is read from `/etc/firezone/gateway-env`. - Both of these changes basically mean the user should never need to touch the unit file itself. - The `sysusers` configuration file ensures the `firezone` user and group are present on the system. - The `tmpfiles` configuration file ensures the necessary directories are present. All of the above is automatically installed and configured using the post-installation script which is called by `apt` once the package is installed. In addition to the Gateway, we also package a first version of the `firezone-cli`. Right now, `firezone-cli` (installed as `firezone`) has three subcommands: - `gateway authenticate`: Asks for the Gateway's token and installs it at `/etc/firezone/gateway-token`. The user doesn't have to know how we manage this token and can trust that we are using safe defaults. - `gateway enable`: Enables and starts the systemd service. - `gateway disable`: Disables the systemd service. Right now, the `.deb` file is only uploaded to the preview APT repository and not attached to the release. It should therefore not yet be user-visible unless somebody pokes around a lot, meaning we can defer documentation to a later PR and start testing it from the preview repository for our own purposes. Related: #10598 Resolves: #8484 Resolves: #10681 --- .github/workflows/_data-plane.yml | 30 +++- rust/Cargo.lock | 9 + rust/Cargo.toml | 1 + rust/cli/Cargo.toml | 16 ++ rust/cli/src/main.rs | 165 ++++++++++++++++++ rust/gateway/Cargo.toml | 19 +- rust/gateway/debian/firezone-gateway-init.sh | 20 +++ rust/gateway/debian/firezone-gateway.service | 76 ++++++++ rust/gateway/debian/firezone-gateway.sysusers | 3 + rust/gateway/debian/firezone-gateway.tmpfiles | 6 + rust/gateway/debian/postinst | 20 +++ 11 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 rust/cli/Cargo.toml create mode 100644 rust/cli/src/main.rs create mode 100644 rust/gateway/debian/firezone-gateway-init.sh create mode 100644 rust/gateway/debian/firezone-gateway.service create mode 100644 rust/gateway/debian/firezone-gateway.sysusers create mode 100644 rust/gateway/debian/firezone-gateway.tmpfiles create mode 100644 rust/gateway/debian/postinst diff --git a/.github/workflows/_data-plane.yml b/.github/workflows/_data-plane.yml index f89491607..2ba9335c0 100644 --- a/.github/workflows/_data-plane.yml +++ b/.github/workflows/_data-plane.yml @@ -264,7 +264,7 @@ jobs: run: ${{ matrix.arch.install_dependencies }} - uses: taiki-e/install-action@d31232495ad76f47aad66e3501e47780b49f0f3e # v2.57.5 with: - tool: bpf-linker + tool: bpf-linker,cargo-deb env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build binaries @@ -318,6 +318,12 @@ jobs: --overwrite true \ --no-progress \ --connection-string "${{ secrets.AZURERM_ARTIFACTS_CONNECTION_STRING }}" + - name: Create Firezone Gateway .deb package + if: ${{ inputs.profile == 'release' && matrix.stage == 'release' && matrix.name.artifact == 'firezone-gateway' }} + run: | + cargo build --bin firezone-cli --release --target ${{ matrix.arch.target }} + cargo deb --package firezone-gateway --target ${{ matrix.arch.target }} --no-build --no-strip + cp target/debian/*.deb "$BINARY_DEST_PATH".deb - name: Upload Release Assets if: ${{ inputs.profile == 'release' && matrix.stage == 'release' && matrix.name.release_name && github.event_name == 'workflow_dispatch' && github.ref_name == 'main' }} env: @@ -339,8 +345,24 @@ jobs: gh release upload ${{ matrix.name.release_name }} \ "$BINARY_DEST_PATH" \ "$BINARY_DEST_PATH".sha256sum.txt \ + # "$BINARY_DEST_PATH".deb \ # Enable this once we have all the necessary documentation in place. "$clobber" \ --repo ${{ github.repository }} + + az storage blob upload-batch \ + --destination apt \ + --source . \ + --pattern "*.deb" \ + --destination-path import-preview \ + --overwrite \ + --no-progress \ + --connection-string "${{ secrets.AZURERM_ARTIFACTS_CONNECTION_STRING }}" + - name: Upload `.deb` artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ env.BINARY_DEST_PATH }}.deb + path: rust/${{ env.BINARY_DEST_PATH }}.deb + retention-days: 1 - name: Set up QEMU uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 - name: Set up Docker Buildx @@ -475,3 +497,9 @@ jobs: # shellcheck disable=SC2086 # $tags and $sources must be split by whitespace docker buildx imagetools create $tags $sources docker buildx imagetools inspect "${{ steps.login.outputs.registry }}/firezone/${{ matrix.image_prefix && format('{0}/', matrix.image_prefix) || '' }}${{ matrix.image.name }}" + + regenerate-apt-index: + needs: data-plane-linux + if: ${{ github.event_name == 'workflow_dispatch' && github.ref_name == 'main' }} + uses: ./.github/workflows/_apt.yml + secrets: inherit diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 2125ca546..df4b0726a 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2389,6 +2389,15 @@ dependencies = [ "zbus 5.11.0", ] +[[package]] +name = "firezone-cli" +version = "1.0.0" +dependencies = [ + "anyhow", + "clap", + "nix 0.30.1", +] + [[package]] name = "firezone-gateway" version = "1.4.18" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index ed3b5e610..1883ea0c1 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "bin-shared", + "cli", "client-ffi", "client-shared", "connlib/bufferpool", diff --git a/rust/cli/Cargo.toml b/rust/cli/Cargo.toml new file mode 100644 index 000000000..0fc2e3ce3 --- /dev/null +++ b/rust/cli/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "firezone-cli" +version = "1.0.0" +edition = { workspace = true } +license = { workspace = true } +description = "CLI for managing Firezone installations" + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } + +[target.'cfg(target_os = "linux")'.dependencies] +nix = { workspace = true, features = ["user"] } + +[lints] +workspace = true diff --git a/rust/cli/src/main.rs b/rust/cli/src/main.rs new file mode 100644 index 000000000..8870cd793 --- /dev/null +++ b/rust/cli/src/main.rs @@ -0,0 +1,165 @@ +#![expect(clippy::print_stdout, reason = "We are a CLI.")] + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; + +const ETC_FIREZONE_GATEWAY_TOKEN: &str = "/etc/firezone/gateway-token"; + +fn main() -> Result<()> { + let cli = Cli::parse(); + + use Component::*; + use GatewayCommand::*; + + match cli.component { + Gateway(Authenticate { replace }) => { + anyhow::ensure!(cfg!(target_os = "linux"), "Only supported Linux right now"); + anyhow::ensure!(is_root(), "Must be executed as root"); + + if let Ok(existing) = std::fs::read_to_string(ETC_FIREZONE_GATEWAY_TOKEN) + && !existing.trim().is_empty() + && !replace + { + anyhow::bail!( + "Found existing token at {ETC_FIREZONE_GATEWAY_TOKEN}, use --replace to overwrite" + ); + } + + let mut token = String::with_capacity(512); // Our tokens are ~270 characters, grab the next power of 2. + + loop { + println!("Paste the token from the portal's deploy page:"); + + let num_bytes = std::io::stdin() + .read_line(&mut token) + .context("Failed to read token from stdin")?; + + if num_bytes == 0 || token.trim().is_empty() { + continue; + } + + break; + } + + install_firezone_gateway_token(token)?; + + println!("Successfully installed token"); + println!("Tip: You can now start the Gateway with `firezone gateway enable`"); + } + Gateway(Enable) => { + anyhow::ensure!(cfg!(target_os = "linux"), "Only supported Linux right now"); + anyhow::ensure!(is_root(), "Must be executed as root"); + + enable_gateway_service().context("Failed to enable `firezone-gateway.service`")?; + + println!("Successfully enabled `firezone-gateway.service`"); + } + Gateway(Disable) => { + anyhow::ensure!(cfg!(target_os = "linux"), "Only supported Linux right now"); + anyhow::ensure!(is_root(), "Must be executed as root"); + + disable_gateway_service().context("Failed to disable `firezone-gateway.service`")?; + + println!("Successfully disabled `firezone-gateway.service`"); + } + } + + Ok(()) +} + +#[derive(Parser, Debug)] +#[command(name = "firezone", bin_name = "firezone", about, long_about = None)] +struct Cli { + #[command(subcommand)] + component: Component, +} + +#[derive(Debug, Subcommand)] +enum Component { + #[command(subcommand)] + Gateway(GatewayCommand), +} + +#[derive(Debug, Subcommand)] +enum GatewayCommand { + /// Securely store the Gateway's token on disk. + Authenticate { + /// If an existing token is found, replace it. + #[arg(long, default_value_t = false)] + replace: bool, + }, + /// Enable the Gateway's systemd service. + Enable, + /// Disable the Gateway's systemd service. + Disable, +} + +#[cfg(target_os = "linux")] +fn is_root() -> bool { + nix::unistd::Uid::current().is_root() +} + +#[cfg(not(target_os = "linux"))] +fn is_root() -> bool { + true +} + +#[cfg(target_os = "linux")] +fn install_firezone_gateway_token(token: String) -> Result<()> { + std::fs::write(ETC_FIREZONE_GATEWAY_TOKEN, token) + .with_context(|| format!("Failed to write token to `{ETC_FIREZONE_GATEWAY_TOKEN}`"))?; + + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn install_firezone_gateway_token(token: String) -> Result<()> { + anyhow::bail!("Not implemented") +} + +#[cfg(target_os = "linux")] +fn enable_gateway_service() -> Result<()> { + use std::process::Command; + + let output = Command::new("systemctl") + .arg("enable") + .arg("--now") + .arg("firezone-gateway.service") + .output()?; + + anyhow::ensure!( + output.status.success(), + "`systemctl enable` exited with {}", + output.status + ); + + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn enable_gateway_service() -> Result<()> { + anyhow::bail!("Not implemented") +} + +#[cfg(target_os = "linux")] +fn disable_gateway_service() -> Result<()> { + use std::process::Command; + + let output = Command::new("systemctl") + .arg("disable") + .arg("firezone-gateway.service") + .output()?; + + anyhow::ensure!( + output.status.success(), + "`systemctl disable` exited with {}", + output.status + ); + + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn disable_gateway_service() -> Result<()> { + anyhow::bail!("Not implemented") +} diff --git a/rust/gateway/Cargo.toml b/rust/gateway/Cargo.toml index 46960023c..fbb75fcdf 100644 --- a/rust/gateway/Cargo.toml +++ b/rust/gateway/Cargo.toml @@ -4,7 +4,24 @@ name = "firezone-gateway" version = "1.4.18" edition = { workspace = true } license = { workspace = true } -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +description = "Gateway for the Firezone zero-trust access product." + +[package.metadata.deb] +maintainer = "Firezone, Inc. " +copyright = "2025 Firezone, Inc." +maintainer-scripts = "debian/" +systemd-units = { enable = false } +extended-description = "" # Empty string to avoid embedding the README +revision = "" +section = "Network" +assets = [ + ["target/release/firezone-gateway", "usr/bin/", "755"], + ["target/release/firezone-cli", "usr/bin/firezone", "755"], + ["debian/firezone-gateway-init.sh", "usr/bin/firezone-gateway-init", "755"], + ["debian/firezone-gateway.sysusers", "usr/lib/sysusers.d/firezone-gateway.conf", "644"], + ["debian/firezone-gateway.tmpfiles", "usr/lib/tmpfiles.d/firezone-gateway.conf", "644"], +] +depends = 'iptables' [dependencies] anyhow = { workspace = true } diff --git a/rust/gateway/debian/firezone-gateway-init.sh b/rust/gateway/debian/firezone-gateway-init.sh new file mode 100644 index 000000000..a48f38af3 --- /dev/null +++ b/rust/gateway/debian/firezone-gateway-init.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +set -ue + +# Enable masquerading for Firezone tunnel traffic +iptables -C FORWARD -i tun-firezone -j ACCEPT >/dev/null 2>&1 || iptables -I FORWARD 1 -i tun-firezone -j ACCEPT +iptables -C FORWARD -o tun-firezone -j ACCEPT >/dev/null 2>&1 || iptables -I FORWARD 1 -o tun-firezone -j ACCEPT +iptables -t nat -C POSTROUTING -s 100.64.0.0/11 -o e+ -j MASQUERADE >/dev/null 2>&1 || iptables -t nat -A POSTROUTING -s 100.64.0.0/11 -o e+ -j MASQUERADE +iptables -t nat -C POSTROUTING -s 100.64.0.0/11 -o w+ -j MASQUERADE >/dev/null 2>&1 || iptables -t nat -A POSTROUTING -s 100.64.0.0/11 -o w+ -j MASQUERADE +ip6tables -C FORWARD -i tun-firezone -j ACCEPT >/dev/null 2>&1 || ip6tables -I FORWARD 1 -i tun-firezone -j ACCEPT +ip6tables -C FORWARD -o tun-firezone -j ACCEPT >/dev/null 2>&1 || ip6tables -I FORWARD 1 -o tun-firezone -j ACCEPT +ip6tables -t nat -C POSTROUTING -s fd00:2021:1111::/107 -o e+ -j MASQUERADE >/dev/null 2>&1 || ip6tables -t nat -A POSTROUTING -s fd00:2021:1111::/107 -o e+ -j MASQUERADE +ip6tables -t nat -C POSTROUTING -s fd00:2021:1111::/107 -o w+ -j MASQUERADE >/dev/null 2>&1 || ip6tables -t nat -A POSTROUTING -s fd00:2021:1111::/107 -o w+ -j MASQUERADE + +# Enable packet forwarding for IPv4 and IPv6 +sysctl -w net.ipv4.ip_forward=1 +sysctl -w net.ipv4.conf.all.src_valid_mark=1 +sysctl -w net.ipv6.conf.all.disable_ipv6=0 +sysctl -w net.ipv6.conf.all.forwarding=1 +sysctl -w net.ipv6.conf.default.forwarding=1 diff --git a/rust/gateway/debian/firezone-gateway.service b/rust/gateway/debian/firezone-gateway.service new file mode 100644 index 000000000..85794984b --- /dev/null +++ b/rust/gateway/debian/firezone-gateway.service @@ -0,0 +1,76 @@ +[Unit] +Description=Firezone Gateway +After=network.target +Documentation=https://www.firezone.dev/kb + +[Service] + +# DO NOT EDIT ANY OF THE BELOW BY HAND. USE "systemctl edit firezone-gateway" INSTEAD TO CUSTOMIZE. +# Most configuration should go as environment variables into `/etc/firezone/gateway-env`. +# The access token should be in `/etc/firezone/gateway-token`. + +Type=simple +User=firezone +Group=firezone +PermissionsStartOnly=true +SyslogIdentifier=firezone-gateway + +LoadCredential=FIREZONE_TOKEN:/etc/firezone/gateway-token +EnvironmentFile=/etc/firezone/gateway-preset-env +EnvironmentFile=/etc/firezone/gateway-env + +ExecStartPre=/usr/bin/firezone-gateway-init +ExecStart=/usr/bin/firezone-gateway + +# Restart on failure +TimeoutStartSec=15s +TimeoutStopSec=15s +Restart=always +RestartSec=7 + +##################### +# HARDENING OPTIONS # +##################### + +# Give the service its own private /tmp directory. +PrivateTmp=true + +# Mount the system directories read-only (except those explicitly allowed). +ProtectSystem=full + +# Make users' home directories read-only. +ProtectHome=read-only + +# Disallow gaining new privileges (e.g. via execve() of setuid binaries). +NoNewPrivileges=true + +# Disallow the creation of new namespaces. +RestrictNamespaces=yes + +# Prevent memory from being both writable and executable. +MemoryDenyWriteExecute=true + +# Prevent the service from calling personality(2) to change process execution domain. +LockPersonality=true + +# Restrict the set of allowed address families. +RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK + +# Allow the process to have CAP_NET_ADMIN (needed for network administration) +# while restricting it to only that capability. +AmbientCapabilities=CAP_NET_ADMIN +CapabilityBoundingSet=CAP_NET_ADMIN + +# Make some sensitive paths inaccessible. +InaccessiblePaths=/root /home + +# Set resource limits +LimitNOFILE=4096 +LimitNPROC=512 +LimitCORE=0 + +# Set a sane system call filter +SystemCallFilter=@system-service + +[Install] +WantedBy=multi-user.target diff --git a/rust/gateway/debian/firezone-gateway.sysusers b/rust/gateway/debian/firezone-gateway.sysusers new file mode 100644 index 000000000..8f2eccf66 --- /dev/null +++ b/rust/gateway/debian/firezone-gateway.sysusers @@ -0,0 +1,3 @@ +# Declarative configuration for sysusers.d, creating a locked down system user named `firezone` and a corresponding group. + +u! firezone diff --git a/rust/gateway/debian/firezone-gateway.tmpfiles b/rust/gateway/debian/firezone-gateway.tmpfiles new file mode 100644 index 000000000..42907982a --- /dev/null +++ b/rust/gateway/debian/firezone-gateway.tmpfiles @@ -0,0 +1,6 @@ +#Type Path Mode User Group Age Argument + +d /etc/firezone 0755 firezone firezone +f /etc/firezone/gateway-env 0644 firezone firezone - +f /etc/firezone/gateway-token 0400 root root - +f /etc/firezone/gateway-preset-env 0644 firezone firezone - diff --git a/rust/gateway/debian/postinst b/rust/gateway/debian/postinst new file mode 100644 index 000000000..661f7184a --- /dev/null +++ b/rust/gateway/debian/postinst @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +# Script inspired by deb helper script from `cargo-deb` +if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ]; then + # In case this system is running systemd, we need to ensure that all + # necessary users are created before starting. + if [ -d /run/systemd/system ]; then + systemd-sysusers firezone-gateway.conf >/dev/null || true + fi +fi + +#DEBHELPER# + +# Generate a deterministic ID based of `/etc/machine-id` +FIREZONE_ID=$(systemd-id128 --app-specific=753b38f9f96947ef8083802d5909a372 machine-id) + +# We fully overwrite the `gateway-present-env` file on every run. +# Maintainer scripts need to be deterministic and that seems to be the easiest way. +printf "# This file is managed by maintainer scripts. Do not touch!\n# Use \`etc/firezone/gateway-env\` for custom configuration.\n" >/etc/firezone/gateway-preset-env +printf "FIREZONE_ID=%s\n" "${FIREZONE_ID}" >>/etc/firezone/gateway-preset-env